Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions pkg/commands/git_commands/repo_paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
1 change: 1 addition & 0 deletions pkg/gui/controllers.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ func (gui *Gui) resetHelpersAndControllers() {
mergeConflictsHelper,
worktreeHelper,
searchHelper,
reposHelper,
)
diffHelper := helpers.NewDiffHelper(helperCommon)
cherryPickHelper := helpers.NewCherryPickHelper(
Expand Down
40 changes: 40 additions & 0 deletions pkg/gui/controllers/helpers/refresh_helper.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package helpers

import (
"os"
"strings"
"sync"
"time"
Expand All @@ -27,6 +28,8 @@ type RefreshHelper struct {
mergeConflictsHelper *MergeConflictsHelper
worktreeHelper *WorktreeHelper
searchHelper *SearchHelper
reposHelper *ReposHelper
switchingToMainOnce sync.Once
}

func NewRefreshHelper(
Expand All @@ -38,6 +41,7 @@ func NewRefreshHelper(
mergeConflictsHelper *MergeConflictsHelper,
worktreeHelper *WorktreeHelper,
searchHelper *SearchHelper,
reposHelper *ReposHelper,
) *RefreshHelper {
return &RefreshHelper{
c: c,
Expand All @@ -48,6 +52,7 @@ func NewRefreshHelper(
mergeConflictsHelper: mergeConflictsHelper,
worktreeHelper: worktreeHelper,
searchHelper: searchHelper,
reposHelper: reposHelper,
}
}

Expand All @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions pkg/i18n/english.go
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,7 @@ type TranslationSet struct {
ErrStageDirWithInlineMergeConflicts string
ErrRepositoryMovedOrDeleted string
ErrWorktreeMovedOrRemoved string
WorktreeDeletedSwitchingToMain string
CommandLog string
ToggleShowCommandLog string
FocusCommandLog string
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions pkg/integration/tests/test_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ var tests = []*components.IntegrationTest{
worktree.DotfileBareRepo,
worktree.DoubleNestedLinkedSubmodule,
worktree.ExcludeFileInWorktree,
worktree.ExternalRemoveCurrentWorktree,
worktree.FastForwardWorktreeBranch,
worktree.FastForwardWorktreeBranchShouldNotPolluteCurrentWorktree,
worktree.ForceRemoveWorktree,
Expand Down
41 changes: 41 additions & 0 deletions pkg/integration/tests/worktree/external_remove_current_worktree.go
Original file line number Diff line number Diff line change
@@ -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"),
)
},
})