diff --git a/pkg/commands/git_commands/file_loader.go b/pkg/commands/git_commands/file_loader.go index 747572b38..8e067ac02 100644 --- a/pkg/commands/git_commands/file_loader.go +++ b/pkg/commands/git_commands/file_loader.go @@ -2,6 +2,7 @@ package git_commands import ( "fmt" + "path/filepath" "strconv" "strings" @@ -16,17 +17,19 @@ type FileLoaderConfig interface { type FileLoader struct { *GitCommon - cmd oscommands.ICmdObjBuilder - config FileLoaderConfig - getFileType func(string) string + cmd oscommands.ICmdObjBuilder + config FileLoaderConfig + getFileType func(string) string + getDirectoryKind func(dirPath string, worktreeGitDirPath string) models.DirectoryKind } func NewFileLoader(gitCommon *GitCommon, cmd oscommands.ICmdObjBuilder, config FileLoaderConfig) *FileLoader { return &FileLoader{ - GitCommon: gitCommon, - cmd: cmd, - getFileType: oscommands.FileType, - config: config, + GitCommon: gitCommon, + cmd: cmd, + getFileType: oscommands.FileType, + getDirectoryKind: directoryKind, + config: config, } } @@ -73,12 +76,27 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File continue } + // git status reports a directory that it doesn't descend into with a + // trailing slash. This is the case for a nested repository, and for + // any untracked directory if status.showUntrackedFiles is "normal". We + // take the slash off so that the file tree shows the directory as a + // single entry; it would otherwise split the path into a directory and + // a file without a name. + path, isDirectory := strings.CutSuffix(status.Path, "/") + file := &models.File{ - Path: status.Path, + Path: path, PreviousPath: status.PreviousPath, DisplayString: status.StatusString, } + if isDirectory { + file.DirectoryKind = self.getDirectoryKind( + filepath.Join(self.repoPaths.WorktreePath(), path), + self.repoPaths.WorktreeGitDirPath(), + ) + } + if diff, ok := fileDiffs[status.Path]; ok { file.LinesAdded = diff.LinesAdded file.LinesDeleted = diff.LinesDeleted @@ -93,6 +111,27 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File return files } +// directoryKind tells what kind of directory git status reported as a whole. +// dirPath is the directory's absolute path, and worktreeGitDirPath is the git +// dir of the worktree that we're in. +func directoryKind(dirPath string, worktreeGitDirPath string) models.DirectoryKind { + gitDir, ok := GitDirOfRepo(dirPath) + if !ok { + return models.PlainDirectory + } + + // git keeps the git dir of a submodule in the "modules" directory of the + // git dir of the worktree that it is checked out in, and puts a .git file + // into the submodule's directory that points there. That .git file stays + // when a commit removes the submodule. + relPath, err := filepath.Rel(filepath.Join(worktreeGitDirPath, "modules"), gitDir) + if err == nil && relPath != "." && !strings.HasPrefix(relPath, "..") { + return models.RemovedSubmodule + } + + return models.NestedRepository +} + // Looks up how long the conflict markers in the conflicted files are. We ask // git for all of them at once, because spawning a process per file would be // painfully slow when hundreds of files are conflicted (especially on Windows). diff --git a/pkg/commands/git_commands/file_loader_test.go b/pkg/commands/git_commands/file_loader_test.go index 23602ff9e..755923da7 100644 --- a/pkg/commands/git_commands/file_loader_test.go +++ b/pkg/commands/git_commands/file_loader_test.go @@ -1,6 +1,8 @@ package git_commands import ( + "os" + "path/filepath" "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" @@ -188,6 +190,27 @@ func TestFileGetStatusFiles(t *testing.T) { }, }, }, + { + testName: "Directory that git doesn't descend into", + similarityThreshold: 50, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "?? dir/nested/", nil), + expectedFiles: []*models.File{ + { + Path: "dir/nested", + HasStagedChanges: false, + HasUnstagedChanges: true, + Tracked: false, + Added: true, + Deleted: false, + HasMergeConflicts: false, + HasInlineMergeConflicts: false, + DisplayString: "?? dir/nested/", + ShortStatus: "??", + DirectoryKind: models.NestedRepository, + }, + }, + }, { testName: "Renamed files", similarityThreshold: 50, @@ -300,6 +323,9 @@ func TestFileGetStatusFiles(t *testing.T) { cmd: cmd, config: &FakeFileLoaderConfig{showUntrackedFiles: "yes"}, getFileType: func(string) string { return "file" }, + getDirectoryKind: func(string, string) models.DirectoryKind { + return models.NestedRepository + }, } assert.EqualValues(t, s.expectedFiles, loader.GetStatusFiles(GetStatusFileOptions{})) @@ -307,6 +333,49 @@ func TestFileGetStatusFiles(t *testing.T) { } } +func TestDirectoryKind(t *testing.T) { + repoDir := t.TempDir() + worktreeGitDir := filepath.Join(repoDir, ".git") + + writeGitFile := func(dir string, gitDir string) { + assert.NoError(t, os.MkdirAll(dir, 0o755)) + assert.NoError(t, os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: "+gitDir+"\n"), 0o644)) + } + + plainDir := filepath.Join(repoDir, "plain") + assert.NoError(t, os.MkdirAll(plainDir, 0o755)) + + nestedRepoDir := filepath.Join(repoDir, "nested") + assert.NoError(t, os.MkdirAll(filepath.Join(nestedRepoDir, ".git"), 0o755)) + + removedSubmoduleDir := filepath.Join(repoDir, "modules", "sub") + writeGitFile(removedSubmoduleDir, "../../.git/modules/sub") + + otherReposSubmoduleDir := filepath.Join(repoDir, "other-sub") + writeGitFile(otherReposSubmoduleDir, filepath.Join(t.TempDir(), ".git", "modules", "sub")) + + worktreeDir := filepath.Join(repoDir, "worktree") + writeGitFile(worktreeDir, filepath.Join(worktreeGitDir, "worktrees", "worktree")) + + scenarios := []struct { + name string + dirPath string + expected models.DirectoryKind + }{ + {"directory without a .git", plainDir, models.PlainDirectory}, + {"repository with a .git directory", nestedRepoDir, models.NestedRepository}, + {"submodule of this repo", removedSubmoduleDir, models.RemovedSubmodule}, + {"submodule of another repo", otherReposSubmoduleDir, models.NestedRepository}, + {"worktree of this repo", worktreeDir, models.NestedRepository}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.expected, directoryKind(s.dirPath, worktreeGitDir)) + }) + } +} + type FakeFileLoaderConfig struct { showUntrackedFiles string } diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 9850b5a4f..967268f88 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -313,3 +313,34 @@ func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) { } return strings.TrimSpace(res), nil } + +// GitDirOfRepo returns the directory that holds the git data of the repo at +// repoPath, and whether it could be found. An ordinary repo keeps that data in +// a .git directory; a worktree and a submodule have a .git file naming the +// directory instead. +func GitDirOfRepo(repoPath string) (string, bool) { + gitDirPath := filepath.Join(repoPath, ".git") + + stat, err := os.Stat(gitDirPath) + if err != nil { + return "", false + } + if stat.IsDir() { + return gitDirPath, true + } + + content, err := os.ReadFile(gitDirPath) + if err != nil { + return "", false + } + gitDir, ok := strings.CutPrefix(strings.TrimSpace(string(content)), "gitdir: ") + if !ok { + return "", false + } + // A relative name is relative to the repo. Git writes one for a submodule, + // and for a worktree created with --relative-paths. + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(repoPath, gitDir) + } + return gitDir, true +} diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 8ebff5910..db68d694f 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -412,6 +412,50 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, mode return self.cmd.New(cmdArgs).DontLog() } +// NestedRepositoryStatusCmdObj returns the command that shows the status of a +// git repository inside the working tree, for the main view +func (self *WorkingTreeCommands) NestedRepositoryStatusCmdObj(path string) *oscommands.CmdObj { + cmdArgs := NewGitCmd("status"). + Config("color.status=always"). + Config("advice.statusHints=false"). + Dir(path). + ToArgv() + + return ForOtherRepo(self.cmd.New(cmdArgs)).DontLog() +} + +// NestedRepositoryHasOwnGitDir reports whether the git repository at the given +// path of the working tree keeps its git dir inside its directory, so that +// discarding the directory discards the repository's history too. A submodule +// or a worktree keeps it elsewhere. +func (self *WorkingTreeCommands) NestedRepositoryHasOwnGitDir(path string) bool { + info, err := os.Stat(filepath.Join(self.repoPaths.worktreePath, path, ".git")) + return err == nil && info.IsDir() +} + +// NestedRepositoryHasUncommittedChanges reports whether the git repository at +// the given path of the working tree has changes that aren't committed, +// including untracked files +func (self *WorkingTreeCommands) NestedRepositoryHasUncommittedChanges(path string) (bool, error) { + cmdArgs := NewGitCmd("status"). + Dir(path). + Arg("--porcelain", "--untracked-files=normal"). + ToArgv() + + output, err := ForOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput() + return strings.TrimSpace(output) != "", err +} + +// UntrackedFilesCmdObj returns the command that lists the untracked files in +// the given directory, for the main view +func (self *WorkingTreeCommands) UntrackedFilesCmdObj(dirPath string) *oscommands.CmdObj { + cmdArgs := NewGitCmd("ls-files"). + Arg("--others", "--exclude-standard", "--", dirPath). + ToArgv() + + return self.cmd.New(cmdArgs).DontLog() +} + // ShowFileDiff get the diff of specified from and to. Typically this will be used for a single commit so it'll be 123abc^..123abc // but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode. // For a renamed file, previousPath is the path it was renamed from (empty otherwise); diff --git a/pkg/commands/models/file.go b/pkg/commands/models/file.go index 9eedfb1fc..1fe75f395 100644 --- a/pkg/commands/models/file.go +++ b/pkg/commands/models/file.go @@ -29,6 +29,35 @@ type File struct { // If true, this must be a worktree folder IsWorktree bool + + // Set if git status reports this entry as a whole directory instead of + // listing the files in it + DirectoryKind DirectoryKind +} + +type DirectoryKind int + +const ( + NotADirectory DirectoryKind = iota + // An untracked directory without a repository of its own. git status + // only reports these as a whole if status.showUntrackedFiles is "normal". + PlainDirectory + // A git repository of its own inside the working tree, which git status + // doesn't descend into. A linked worktree of this repo inside the working + // tree is one of these too; IsWorktree tells it apart. + NestedRepository + // A nested repository whose git dir is among the git dirs that git keeps + // for the submodules of this repo. It is what is left of a submodule after + // checking out a commit that removed it: git takes the submodule out of + // the index, but leaves its directory behind. + RemovedSubmodule +) + +// IsNestedRepository reports whether the file is a git repository of its own +// inside the working tree, such as a linked worktree or the directory of a +// removed submodule +func (f *File) IsNestedRepository() bool { + return f.DirectoryKind == NestedRepository || f.DirectoryKind == RemovedSubmodule } // sometimes we need to deal with either a node (which contains a file) or an actual file diff --git a/pkg/gui/context/working_tree_context.go b/pkg/gui/context/working_tree_context.go index 706bed3b6..8b428e15b 100644 --- a/pkg/gui/context/working_tree_context.go +++ b/pkg/gui/context/working_tree_context.go @@ -34,7 +34,7 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext { getDisplayStrings := func(_ int, _ int) [][]string { showFileIcons := icons.IsIconEnabled() && c.UserConfig().Gui.ShowFileIcons showNumstat := c.UserConfig().Gui.ShowNumstatInFilesView - lines := presentation.RenderFileTree(viewModel, c.Model().Submodules, showFileIcons, showNumstat, &c.UserConfig().Gui.CustomIcons, c.UserConfig().Gui.ShowRootItemInFileTree) + lines := presentation.RenderFileTree(viewModel, c.Model().Submodules, showFileIcons, showNumstat, &c.UserConfig().Gui.CustomIcons, c.UserConfig().Gui.ShowRootItemInFileTree, c.Tr) return lo.Map(lines, func(line string, _ int) []string { return []string{line} }) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index ce53ef65b..c696f0406 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -9,6 +9,7 @@ import ( "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/filetree" @@ -136,7 +137,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types { Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll), Handler: self.toggleStagedAll, - GetDisabledReason: self.require(self.anyFilesDisplayed), + GetDisabledReason: self.require(self.anyFilesDisplayed, self.canStageAll), Description: self.c.Tr.ToggleStagedAll, Tooltip: self.c.Tr.ToggleStagedAllTooltip, }, @@ -275,11 +276,68 @@ func (self *FilesController) GetOnRenderToMain() func() { return } + if node.File != nil && node.File.DirectoryKind != models.NotADirectory { + self.renderDirectoryEntry(node.File) + return + } + self.renderWorkingTreeDiff(node) }) } } +// renderDirectoryEntry renders an entry that git status reports as a whole +// directory. It explains what the directory is and what to do about it, and +// below a divider shows the status of the repository that it is, or the files +// in it if it isn't one. +func (self *FilesController) renderDirectoryEntry(file *models.File) { + self.c.Helpers().MergeConflicts.ResetMergeState() + + placeholders := map[string]string{"path": file.Path} + var description string + var heading string + var cmdObj *oscommands.CmdObj + if file.DirectoryKind == models.PlainDirectory { + description = utils.ResolvePlaceholderString(self.c.Tr.PlainDirectoryDescription, placeholders) + heading = utils.ResolvePlaceholderString(self.c.Tr.PlainDirectoryFiles, placeholders) + cmdObj = self.c.Git().WorkingTree.UntrackedFilesCmdObj(file.Path) + } else { + description = self.nestedRepositoryDescription(file) + heading = utils.ResolvePlaceholderString(self.c.Tr.NestedRepositoryStatus, placeholders) + cmdObj = self.c.Git().WorkingTree.NestedRepositoryStatusCmdObj(file.Path) + } + + prefix := types.PrefixForWidth(func(width int) string { + return wrapForReadability(description, width) + "\n\n" + titledRule(heading, width) + "\n\n" + }) + self.renderToMainWithTask(types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix)) +} + +// titledRule returns a horizontal line across the given width with a title in +// it, for dividing a view's content into sections +func titledRule(title string, width int) string { + start := "── " + title + " " + return start + strings.Repeat("─", max(width-utils.StringWidth(start), 0)) +} + +func (self *FilesController) nestedRepositoryDescription(file *models.File) string { + placeholders := self.c.Helpers().WorkingTree.NestedRepositoryPlaceholders(file) + + switch { + case file.IsWorktree: + return utils.ResolvePlaceholderString(self.c.Tr.WorktreeInsideRepoDescription, placeholders) + case file.DirectoryKind == models.RemovedSubmodule: + return utils.ResolvePlaceholderString(self.c.Tr.RemovedSubmoduleDescription, placeholders) + default: + discardHint := self.c.Tr.NestedRepositoryDiscardHint + if self.c.Git().WorkingTree.NestedRepositoryHasOwnGitDir(file.Path) { + discardHint = self.c.Tr.NestedRepositoryDiscardWarning + } + return utils.ResolvePlaceholderString( + self.c.Tr.NestedRepositoryDescription+"\n\n"+discardHint, placeholders) + } +} + // renderToMainWithTask renders the given task to the main view with the standard // diff title and subtitle. func (self *FilesController) renderToMainWithTask(task types.UpdateTask) { @@ -749,11 +807,18 @@ func (self *FilesController) enterFile(clickedViewLineIdx int) error { func (self *FilesController) conflictResolutionHint(description string) string { message := description + "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve, self.c.UserConfig().Keybinding.Universal.GoInto) - if self.c.Views().Main.InnerWidth() > 70 { - lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4) - message = strings.Join(lines, "\n") + return wrapForReadability(message, self.c.Views().Main.InnerWidth()) +} + +// wrapForReadability wraps a message for the main view when the view is wide +// enough that long lines would otherwise hurt readability. +func wrapForReadability(message string, viewWidth int) string { + const readableWidth = 70 + if viewWidth <= readableWidth { + return message } - return message + lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, readableWidth, 4) + return strings.Join(lines, "\n") } // conflictNeedsResolutionDialog reports whether a file's merge conflict can only @@ -777,7 +842,8 @@ func (self *FilesController) conflictNeedsResolutionDialog(file *models.File) bo // canStageSelection disables staging when a multiple selection includes a file // with a conflict that must be resolved through a dialog; those have to be -// resolved one at a time. +// resolved one at a time. It also disables it when the selection includes a +// nested repository. func (self *FilesController) canStageSelection(nodes []*filetree.FileNode) *types.DisabledReason { if len(nodes) > 1 { for _, node := range nodes { @@ -793,6 +859,33 @@ func (self *FilesController) canStageSelection(nodes []*filetree.FileNode) *type } } + for _, node := range nodes { + if nestedRepository := node.FindFirstFileBy((*models.File).IsNestedRepository); nestedRepository != nil { + return self.c.Helpers().WorkingTree.CantStageNestedRepository(nestedRepository) + } + } + + return nil +} + +// canStageAll disables staging all files when that would stage a nested +// repository. +func (self *FilesController) canStageAll() *types.DisabledReason { + var nestedRepository *models.File + switch self.stageAllScope() { + case stageAllVisibleFiles: + nestedRepository = self.context().FileTreeViewModel.GetRoot().FindFirstFileBy((*models.File).IsNestedRepository) + case stageAllTrackedFiles: + // A nested repository is never tracked + return nil + default: + nestedRepository, _ = lo.Find(self.c.Model().Files, (*models.File).IsNestedRepository) + } + + if nestedRepository != nil { + return self.c.Helpers().WorkingTree.CantStageNestedRepository(nestedRepository) + } + return nil } @@ -928,22 +1021,44 @@ func (self *FilesController) toggleStagedAll() error { return nil } +// What staging all files stages +type stageAllScope int + +const ( + // Only the files that are visible, because a text filter is active + stageAllVisibleFiles stageAllScope = iota + // Only tracked files, because only those are shown + stageAllTrackedFiles + stageAllFiles +) + +func (self *FilesController) stageAllScope() stageAllScope { + if self.context().IsFiltering() { + return stageAllVisibleFiles + } + if self.context().GetStatusFilter() == filetree.DisplayTracked { + return stageAllTrackedFiles + } + return stageAllFiles +} + func (self *FilesController) toggleStagedAllWithLock() error { root := self.context().FileTreeViewModel.GetRoot() stage := func(unstagedNodes []*filetree.FileNode) error { - if self.context().IsFiltering() { - // When filtering, only stage visible files + switch self.stageAllScope() { + case stageAllVisibleFiles: var paths []string _ = root.ForEachFile(func(file *models.File) error { paths = append(paths, file.Path) return nil }) return self.c.Git().WorkingTree.StageFiles(paths, nil) + case stageAllTrackedFiles: + return self.c.Git().WorkingTree.StageAll(true) + default: + return self.c.Git().WorkingTree.StageAll(false) } - - onlyTrackedFiles := self.context().GetStatusFilter() == filetree.DisplayTracked - return self.c.Git().WorkingTree.StageAll(onlyTrackedFiles) } unstage := func(nodes []*filetree.FileNode) error { @@ -1691,24 +1806,41 @@ func findSubmoduleNode(nodes []*filetree.FileNode, submodules []*models.Submodul return nil } +func findWorktreeNode(nodes []*filetree.FileNode) *models.File { + for _, node := range nodes { + if worktreeFile := node.FindFirstFileBy(func(f *models.File) bool { return f.IsWorktree }); worktreeFile != nil { + return worktreeFile + } + } + return nil +} + func (self *FilesController) canRemove(selectedNodes []*filetree.FileNode) *types.DisabledReason { - // Return disabled if the selection contains multiple changed items and includes a submodule change. + // Return disabled if the selection contains multiple changed items and + // includes a submodule change or a worktree. These have menus of their own. submodules := self.c.Model().Submodules hasFiles := false uniqueSelectedSubmodules := set.New[*models.SubmoduleConfig]() + uniqueSelectedWorktreePaths := set.New[string]() for _, node := range selectedNodes { _ = node.ForEachFile(func(f *models.File) error { if submodule := f.SubmoduleConfig(submodules); submodule != nil { uniqueSelectedSubmodules.Add(submodule) + } else if f.IsWorktree { + uniqueSelectedWorktreePaths.Add(f.Path) } else { hasFiles = true } return nil }) - if uniqueSelectedSubmodules.Len() > 0 && (hasFiles || uniqueSelectedSubmodules.Len() > 1) { + if uniqueSelectedSubmodules.Len() > 0 && + (hasFiles || uniqueSelectedWorktreePaths.Len() > 0 || uniqueSelectedSubmodules.Len() > 1) { return &types.DisabledReason{Text: self.c.Tr.MultiSelectNotSupportedForSubmodules} } + if uniqueSelectedWorktreePaths.Len() > 0 && (hasFiles || uniqueSelectedWorktreePaths.Len() > 1) { + return &types.DisabledReason{Text: self.c.Tr.MultiSelectNotSupportedForWorktrees} + } } return nil @@ -1737,22 +1869,31 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { return self.c.Menu(types.CreateMenuOptions{Title: submoduleNode.GetPath(), Items: menuItems}) } + // Likewise, if we have a worktree, it is the only thing selected + if worktreeFile := findWorktreeNode(selectedNodes); worktreeFile != nil { + if worktree, ok := self.c.Helpers().WorkingTree.WorktreeForFile(worktreeFile); ok { + return self.c.Helpers().BranchesHelper.RemoveWorktreeMenu(worktree) + } + } + discardAllChangesItem := types.MenuItem{ Label: self.c.Tr.DiscardAllChanges, OnPress: func() error { - self.c.LogAction(self.c.Tr.Actions.DiscardAllChangesInFile) + return self.confirmDiscardingNestedRepositories(selectedNodes, func() error { + self.c.LogAction(self.c.Tr.Actions.DiscardAllChangesInFile) - if self.context().IsSelectingRange() { - defer self.context().CancelRangeSelect() - } + if self.context().IsSelectingRange() { + defer self.context().CancelRangeSelect() + } - nodes := lo.Map(selectedNodes, func(n *filetree.FileNode, _ int) git_commands.IFileNode { return n }) - if err := self.c.Git().WorkingTree.DiscardAllDirChanges(nodes); err != nil { - return err - } + nodes := lo.Map(selectedNodes, func(n *filetree.FileNode, _ int) git_commands.IFileNode { return n }) + if err := self.c.Git().WorkingTree.DiscardAllDirChanges(nodes); err != nil { + return err + } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) - return nil + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) + return nil + }) }, Keys: self.c.KeybindingsOpts().GetKeys(self.c.UserConfig().Keybinding.Files.ConfirmDiscard), Tooltip: utils.ResolvePlaceholderString( @@ -1766,19 +1907,21 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { discardUnstagedChangesItem := types.MenuItem{ Label: self.c.Tr.DiscardUnstagedChanges, OnPress: func() error { - self.c.LogAction(self.c.Tr.Actions.DiscardAllUnstagedChangesInFile) + return self.confirmDiscardingNestedRepositories(selectedNodes, func() error { + self.c.LogAction(self.c.Tr.Actions.DiscardAllUnstagedChangesInFile) - if self.context().IsSelectingRange() { - defer self.context().CancelRangeSelect() - } + if self.context().IsSelectingRange() { + defer self.context().CancelRangeSelect() + } - nodes := lo.Map(selectedNodes, func(n *filetree.FileNode, _ int) git_commands.IFileNode { return n }) - if err := self.c.Git().WorkingTree.DiscardUnstagedDirChanges(nodes); err != nil { - return err - } + nodes := lo.Map(selectedNodes, func(n *filetree.FileNode, _ int) git_commands.IFileNode { return n }) + if err := self.c.Git().WorkingTree.DiscardUnstagedDirChanges(nodes); err != nil { + return err + } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) - return nil + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) + return nil + }) }, Keys: menuKey('u'), Tooltip: utils.ResolvePlaceholderString( @@ -1801,6 +1944,60 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.DiscardChangesTitle, Items: menuItems}) } +// confirmDiscardingNestedRepositories calls discard, but asks first if +// discarding the given nodes would discard something of a nested repository in +// them that can't be restored: the repository itself, if it keeps its git dir +// inside its directory, or changes that it has that aren't committed. +func (self *FilesController) confirmDiscardingNestedRepositories(nodes []*filetree.FileNode, discard func() error) error { + var nestedRepositories []*models.File + for _, node := range nodes { + _ = node.ForEachFile(func(file *models.File) error { + if file.IsNestedRepository() { + nestedRepositories = append(nestedRepositories, file) + } + return nil + }) + } + + if len(nestedRepositories) == 0 { + return discard() + } + + return self.c.WithWaitingStatus(self.c.Tr.CheckingNestedRepositories, func(gocui.Task) error { + var losses []string + for _, file := range nestedRepositories { + placeholders := map[string]string{"path": file.Path} + if self.c.Git().WorkingTree.NestedRepositoryHasOwnGitDir(file.Path) { + losses = append(losses, utils.ResolvePlaceholderString( + self.c.Tr.NestedRepositoryHistoryWouldBeLost, placeholders)) + continue + } + + // If we can't tell, assume that there are changes, so that we ask + hasChanges, err := self.c.Git().WorkingTree.NestedRepositoryHasUncommittedChanges(file.Path) + if hasChanges || err != nil { + losses = append(losses, utils.ResolvePlaceholderString( + self.c.Tr.NestedRepositoryChangesWouldBeLost, placeholders)) + } + } + + self.c.OnUIThread(func() error { + if len(losses) == 0 { + return discard() + } + + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.DiscardChangesTitle, + Prompt: self.c.Tr.DiscardNestedRepositoriesPrompt + "\n\n- " + strings.Join(losses, "\n- "), + HandleConfirm: discard, + }) + return nil + }) + + return nil + }) +} + func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) error { file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule) return self.c.WithWaitingStatus(self.c.Tr.ResettingSubmoduleStatus, func(gocui.Task) error { diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 5f26c8099..8117f4bae 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -231,10 +231,70 @@ func (self *BranchesHelper) promptWorktreeBranchDelete( }) } +// RemoveWorktreeMenu shows the menu for removing a worktree, optionally along +// with the branch that it has checked out. +func (self *BranchesHelper) RemoveWorktreeMenu(worktree *models.Worktree) error { + if worktree.IsMain { + return errors.New(self.c.Tr.CantDeleteMainWorktree) + } + + if worktree.IsCurrent { + return errors.New(self.c.Tr.CantDeleteCurrentWorktree) + } + + removeWorktreeItem := &types.MenuItem{ + Label: self.c.Tr.RemoveWorktree, + Keys: menuKey('w'), + OnPress: func() error { + return self.worktreeHelper.Remove(worktree, nil) + }, + } + + branch, branchFound := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool { + return branch.Name == worktree.Branch + }) + // A worktree with a detached HEAD has no branch to delete + detachedReason := &types.DisabledReason{Text: self.c.Tr.WorktreeNotCheckedOutOnBranch} + + removeWorktreeAndBranchItem := &types.MenuItem{ + Label: self.c.Tr.RemoveWorktreeAndDeleteBranch, + Keys: menuKey('b'), + OnPress: func() error { + return self.RemoveWorktreeAndDeleteBranch(worktree, branch) + }, + } + if !branchFound { + removeWorktreeAndBranchItem.DisabledReason = detachedReason + } + + removeWorktreeAndBothBranchesItem := &types.MenuItem{ + Label: self.c.Tr.RemoveWorktreeAndDeleteBothBranches, + Keys: menuKey('r'), + OnPress: func() error { + return self.RemoveWorktreeAndDeleteBothBranches(worktree, branch) + }, + } + if !branchFound { + removeWorktreeAndBothBranchesItem.DisabledReason = detachedReason + } else if !branch.IsTrackingRemote() || branch.UpstreamGone { + removeWorktreeAndBothBranchesItem.DisabledReason = &types.DisabledReason{ + Text: self.c.Tr.UpstreamNotSetError, + } + } + + return self.c.Menu(types.CreateMenuOptions{ + Title: utils.ResolvePlaceholderString( + self.c.Tr.RemoveWorktreeMenuTitle, + map[string]string{"worktreeName": worktree.Name}, + ), + Items: []*types.MenuItem{removeWorktreeItem, removeWorktreeAndBranchItem, removeWorktreeAndBothBranchesItem}, + }) +} + // RemoveWorktreeAndDeleteBranch removes the worktree and deletes the local branch // it has checked out, force-warning first if the branch isn't fully merged. It's -// the worktrees-panel counterpart to deleting a worktree-checked-out branch from -// the branches panel. +// the remove-worktree menu's counterpart to deleting a worktree-checked-out +// branch from the branches panel. func (self *BranchesHelper) RemoveWorktreeAndDeleteBranch( worktree *models.Worktree, branch *models.Branch, ) error { diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 826d12400..4b3d1c429 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1467,9 +1467,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re } // markWorktreeFiles marks the files that are linked worktrees of this repo, so -// that the files view can render them as such. `git status` reports a worktree -// as an untracked directory, i.e. with a trailing slash, which we take off: -// keeping it would build a directory node with a nameless file inside it. +// that the files view can render them as such. // // It must run on the UI thread, as it works on the model. Both models it needs // are written by refreshes of their own, so it is called after either of them @@ -1478,26 +1476,26 @@ func markWorktreeFiles(files []*models.File, worktrees []*models.Worktree, workt changed := false for _, file := range files { - absPath := filepath.Join(worktreePath, file.Path) - isWorktree := lo.SomeBy(worktrees, func(worktree *models.Worktree) bool { - return worktree.Path == absPath - }) + _, isWorktree := worktreeOfFile(file, worktrees, worktreePath) if isWorktree != file.IsWorktree { file.IsWorktree = isWorktree changed = true } - if isWorktree { - if trimmed := strings.TrimSuffix(file.Path, "/"); trimmed != file.Path { - file.Path = trimmed - changed = true - } - } } return changed } +// worktreeOfFile returns the worktree that an entry of the files panel is, if +// it is one. worktreePath is the path of the worktree that we're in. +func worktreeOfFile(file *models.File, worktrees []*models.Worktree, worktreePath string) (*models.Worktree, bool) { + absPath := filepath.Join(worktreePath, file.Path) + return lo.Find(worktrees, func(worktree *models.Worktree) bool { + return worktree.Path == absPath + }) +} + // the reflogs panel is the only panel where we cache data, in that we only // load entries that have been created since we last ran the call. This means // we need to be more careful with how we use this, and to ensure we're emptying diff --git a/pkg/gui/controllers/helpers/refresh_helper_test.go b/pkg/gui/controllers/helpers/refresh_helper_test.go index 5e58c56a3..1ef4b581a 100644 --- a/pkg/gui/controllers/helpers/refresh_helper_test.go +++ b/pkg/gui/controllers/helpers/refresh_helper_test.go @@ -309,12 +309,12 @@ func TestMarkWorktreeFiles(t *testing.T) { {Path: filepath.Join("/", "path", "to", "worktree3")}, } - t.Run("marks the files that are worktrees, and takes their slash off", func(t *testing.T) { + t.Run("marks the files that are worktrees", func(t *testing.T) { files := []*models.File{ {Path: "file"}, - {Path: "worktree1/"}, - {Path: "dir/worktree2/"}, - {Path: "dir/"}, + {Path: "worktree1"}, + {Path: "dir/worktree2"}, + {Path: "dir"}, } assert.True(t, markWorktreeFiles(files, worktrees, worktreePath)) @@ -322,12 +322,12 @@ func TestMarkWorktreeFiles(t *testing.T) { {Path: "file"}, {Path: "worktree1", IsWorktree: true}, {Path: "dir/worktree2", IsWorktree: true}, - {Path: "dir/"}, + {Path: "dir"}, }, files) }) t.Run("reports no change when there is nothing to mark", func(t *testing.T) { - files := []*models.File{{Path: "file"}, {Path: "dir/"}} + files := []*models.File{{Path: "file"}, {Path: "dir"}} assert.False(t, markWorktreeFiles(files, worktrees, worktreePath)) }) diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 00cfbbd0a..b31185659 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -70,37 +70,6 @@ type headInfo struct { hash string } -// gitDirOfRepo returns the directory that holds the git data of the repo at -// repoPath, and whether it could be found. An ordinary repo keeps that data in -// a .git directory; a worktree and a submodule have a .git file naming the -// directory instead. -func gitDirOfRepo(repoPath string) (string, bool) { - gitDirPath := filepath.Join(repoPath, ".git") - - stat, err := os.Stat(gitDirPath) - if err != nil { - return "", false - } - if stat.IsDir() { - return gitDirPath, true - } - - content, err := os.ReadFile(gitDirPath) - if err != nil { - return "", false - } - gitDir, ok := strings.CutPrefix(strings.TrimSpace(string(content)), "gitdir: ") - if !ok { - return "", false - } - // A relative name is relative to the repo. Git writes one for a submodule, - // and for a worktree created with --relative-paths. - if !filepath.IsAbs(gitDir) { - gitDir = filepath.Join(repoPath, gitDir) - } - return gitDir, true -} - // The branch git names in the HEAD file of a repo that keeps its refs in a // reftable. The refs live in a binary table there, and the name in HEAD // resolves nowhere, so that a reader of the file gets an error instead of a @@ -110,7 +79,7 @@ const reftablePlaceholderBranch = ".invalid" // readHeadInfo reads the HEAD file of the repo at repoPath to find out what it // has checked out, and reports whether that worked. func readHeadInfo(repoPath string) (headInfo, bool) { - gitDir, ok := gitDirOfRepo(repoPath) + gitDir, ok := git_commands.GitDirOfRepo(repoPath) if !ok { return headInfo{}, false } diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 5f68cb822..a11830649 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -13,6 +13,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -113,6 +114,39 @@ func (self *WorkingTreeHelper) FileForSubmodule(submodule *models.SubmoduleConfi return nil } +// WorktreeForFile returns the worktree that an entry of the files panel is, if +// it is one +func (self *WorkingTreeHelper) WorktreeForFile(file *models.File) (*models.Worktree, bool) { + return worktreeOfFile(file, self.c.Model().Worktrees, self.c.Git().RepoPaths.WorktreePath()) +} + +// NestedRepositoryPlaceholders returns the placeholders for the texts that +// tell you what to do about a nested repository +func (self *WorkingTreeHelper) NestedRepositoryPlaceholders(file *models.File) map[string]string { + keybindings := self.c.UserConfig().Keybinding + return map[string]string{ + "path": file.Path, + "discardKey": keybindings.Universal.Remove.String(), + "removeKey": keybindings.Universal.Remove.String(), + "ignoreKey": keybindings.Files.IgnoreFile.String(), + } +} + +// CantStageNestedRepository returns why the given nested repository can't be +// staged. git add records a nested repository as an embedded repository: a +// gitlink without an entry in .gitmodules. Nobody wants that, so we don't let +// you stage one, and say how to get rid of it instead. +func (self *WorkingTreeHelper) CantStageNestedRepository(file *models.File) *types.DisabledReason { + text := self.c.Tr.StageNestedRepositoryDisabled + if file.IsWorktree { + text = self.c.Tr.StageWorktreeDisabled + } + + return &types.DisabledReason{ + Text: utils.ResolvePlaceholderString(text, self.NestedRepositoryPlaceholders(file)), + } +} + func (self *WorkingTreeHelper) OpenMergeTool() error { self.c.LogAction(self.c.Tr.Actions.OpenMergeTool) return self.c.RunSubprocessAndRefresh( @@ -232,6 +266,10 @@ func (self *WorkingTreeHelper) WithEnsureCommittableFiles(handler func() error) } if !self.AnyStagedFiles() { + if nestedRepository, ok := lo.Find(self.c.Model().Files, (*models.File).IsNestedRepository); ok { + return errors.New(self.CantStageNestedRepository(nestedRepository).Text) + } + if self.c.UserConfig().Gui.SkipNoStagedFilesWarning { self.c.LogAction(self.c.Tr.Actions.StageAllFiles) if err := self.c.Git().WorkingTree.StageAll(false); err != nil { diff --git a/pkg/gui/controllers/worktrees_controller.go b/pkg/gui/controllers/worktrees_controller.go index 02d20b3f2..886cf9efd 100644 --- a/pkg/gui/controllers/worktrees_controller.go +++ b/pkg/gui/controllers/worktrees_controller.go @@ -1,7 +1,6 @@ package controllers import ( - "errors" "fmt" "strings" "text/tabwriter" @@ -11,7 +10,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" - "github.com/samber/lo" ) type WorktreesController struct { @@ -123,61 +121,7 @@ func (self *WorktreesController) add() error { } func (self *WorktreesController) remove(worktree *models.Worktree) error { - if worktree.IsMain { - return errors.New(self.c.Tr.CantDeleteMainWorktree) - } - - if worktree.IsCurrent { - return errors.New(self.c.Tr.CantDeleteCurrentWorktree) - } - - removeWorktreeItem := &types.MenuItem{ - Label: self.c.Tr.RemoveWorktree, - Keys: menuKey('w'), - OnPress: func() error { - return self.c.Helpers().Worktree.Remove(worktree, nil) - }, - } - - branch, branchFound := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool { - return branch.Name == worktree.Branch - }) - // A worktree with a detached HEAD has no branch to delete - detachedReason := &types.DisabledReason{Text: self.c.Tr.WorktreeNotCheckedOutOnBranch} - - removeWorktreeAndBranchItem := &types.MenuItem{ - Label: self.c.Tr.RemoveWorktreeAndDeleteBranch, - Keys: menuKey('b'), - OnPress: func() error { - return self.c.Helpers().BranchesHelper.RemoveWorktreeAndDeleteBranch(worktree, branch) - }, - } - if !branchFound { - removeWorktreeAndBranchItem.DisabledReason = detachedReason - } - - removeWorktreeAndBothBranchesItem := &types.MenuItem{ - Label: self.c.Tr.RemoveWorktreeAndDeleteBothBranches, - Keys: menuKey('r'), - OnPress: func() error { - return self.c.Helpers().BranchesHelper.RemoveWorktreeAndDeleteBothBranches(worktree, branch) - }, - } - if !branchFound { - removeWorktreeAndBothBranchesItem.DisabledReason = detachedReason - } else if !branch.IsTrackingRemote() || branch.UpstreamGone { - removeWorktreeAndBothBranchesItem.DisabledReason = &types.DisabledReason{ - Text: self.c.Tr.UpstreamNotSetError, - } - } - - return self.c.Menu(types.CreateMenuOptions{ - Title: utils.ResolvePlaceholderString( - self.c.Tr.RemoveWorktreeMenuTitle, - map[string]string{"worktreeName": worktree.Name}, - ), - Items: []*types.MenuItem{removeWorktreeItem, removeWorktreeAndBranchItem, removeWorktreeAndBothBranchesItem}, - }) + return self.c.Helpers().BranchesHelper.RemoveWorktreeMenu(worktree) } func (self *WorktreesController) GetOnDoubleClick() func() error { diff --git a/pkg/gui/presentation/files.go b/pkg/gui/presentation/files.go index 534229905..813714f2c 100644 --- a/pkg/gui/presentation/files.go +++ b/pkg/gui/presentation/files.go @@ -10,6 +10,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" + "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" ) @@ -26,12 +27,13 @@ func RenderFileTree( showNumstat bool, customIconsConfig *config.CustomIconsConfig, showRootItem bool, + tr *i18n.TranslationSet, ) []string { collapsedPaths := tree.CollapsedPaths() return renderAux(tree.GetRoot().Raw(), collapsedPaths, -1, -1, func(node *filetree.Node[models.File], treeDepth int, visualDepth int, isCollapsed bool) string { fileNode := filetree.NewFileNode(node) - return getFileLine(isCollapsed, fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), treeDepth, visualDepth, showNumstat, showFileIcons, submoduleConfigs, node, customIconsConfig, showRootItem) + return getFileLine(isCollapsed, fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), treeDepth, visualDepth, showNumstat, showFileIcons, submoduleConfigs, node, customIconsConfig, showRootItem, tr) }) } @@ -121,6 +123,7 @@ func getFileLine( node *filetree.Node[models.File], customIconsConfig *config.CustomIconsConfig, showRootItem bool, + tr *i18n.TranslationSet, ) string { name := fileNameAtDepth(node, treeDepth, showRootItem) output := "" @@ -158,18 +161,23 @@ func getFileLine( isSubmodule := file != nil && file.IsSubmodule(submoduleConfigs) isLinkedWorktree := file != nil && file.IsWorktree - isDirectory := file == nil + isDirectory := file == nil || file.DirectoryKind != models.NotADirectory if showFileIcons { - icon := icons.IconForFile(name, isSubmodule, isLinkedWorktree, isDirectory, customIconsConfig) + isRemovedSubmodule := file != nil && file.DirectoryKind == models.RemovedSubmodule + icon := icons.IconForFile(name, isSubmodule || isRemovedSubmodule, isLinkedWorktree, isDirectory, customIconsConfig) paint := color.HEX(icon.Color, false) output += paint.Sprint(icon.Icon) + nameColor.Sprint(" ") } output += nameColor.Sprint(utils.EscapeSpecialChars(name)) - if isSubmodule { - output += theme.DefaultTextColor.Sprint(" (submodule)") + if file != nil && file.DirectoryKind == models.PlainDirectory { + output += nameColor.Sprint("/") + } + + if label := fileLabel(file, isSubmodule, tr); label != "" { + output += theme.DefaultTextColor.Sprint(" (" + label + ")") } if file != nil && showNumstat { @@ -181,6 +189,26 @@ func getFileLine( return output } +// fileLabel returns what to show in parentheses after the name of an entry of +// the files panel that is something other than a file or directory of this +// repo, or "" if it isn't. +func fileLabel(file *models.File, isSubmodule bool, tr *i18n.TranslationSet) string { + switch { + case file == nil: + return "" + case isSubmodule: + return tr.SubmoduleLabel + case file.IsWorktree: + return tr.WorktreeLabel + case file.DirectoryKind == models.RemovedSubmodule: + return tr.RemovedSubmoduleLabel + case file.DirectoryKind == models.NestedRepository: + return tr.NestedRepositoryLabel + default: + return "" + } +} + func formatFileStatus(file *models.File, restColor style.TextStyle) string { firstChar := file.ShortStatus[0:1] firstCharCl := style.FgGreen diff --git a/pkg/gui/presentation/files_test.go b/pkg/gui/presentation/files_test.go index 9a92731a0..2189eed11 100644 --- a/pkg/gui/presentation/files_test.go +++ b/pkg/gui/presentation/files_test.go @@ -60,6 +60,24 @@ func TestRenderFileTree(t *testing.T) { " M test4", }, }, + { + name: "directories that git status reports as a whole", + files: []*models.File{ + {Path: "modules/sub", ShortStatus: "??", DirectoryKind: models.RemovedSubmodule}, + {Path: "nested", ShortStatus: "??", DirectoryKind: models.NestedRepository}, + {Path: "plain", ShortStatus: "??", DirectoryKind: models.PlainDirectory}, + {Path: "worktree", ShortStatus: "??", DirectoryKind: models.NestedRepository, IsWorktree: true}, + }, + expected: toStringSlice( + ` +▼ modules + ?? sub (removed submodule) +?? nested (nested repository) +?? plain/ +?? worktree (worktree) +`, + ), + }, { name: "big example", files: []*models.File{ @@ -123,7 +141,7 @@ M file1 for _, path := range s.collapsedPaths { viewModel.ToggleCollapsed(path) } - result := RenderFileTree(viewModel, nil, false, s.showLineChanges, &config.CustomIconsConfig{}, s.showRootItem) + result := RenderFileTree(viewModel, nil, false, s.showLineChanges, &config.CustomIconsConfig{}, s.showRootItem, common.Tr) assert.EqualValues(t, s.expected, result) }) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 0fb388216..b71d084a6 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -103,6 +103,24 @@ type TranslationSet struct { MergeConflictTakeCurrentCommit string MergeConflictTakeIncomingCommit string SubmoduleMergeConflictDescription string + SubmoduleLabel string + RemovedSubmoduleLabel string + NestedRepositoryLabel string + WorktreeLabel string + RemovedSubmoduleDescription string + NestedRepositoryDescription string + NestedRepositoryDiscardHint string + NestedRepositoryDiscardWarning string + WorktreeInsideRepoDescription string + NestedRepositoryStatus string + PlainDirectoryDescription string + PlainDirectoryFiles string + StageNestedRepositoryDisabled string + StageWorktreeDisabled string + CheckingNestedRepositories string + DiscardNestedRepositoriesPrompt string + NestedRepositoryHistoryWouldBeLost string + NestedRepositoryChangesWouldBeLost string StageConflictsRangeDisabled string Checkout string CheckoutTooltip string @@ -999,6 +1017,7 @@ type TranslationSet struct { SelectedItemIsNotABranch string SelectedItemDoesNotHaveFiles string MultiSelectNotSupportedForSubmodules string + MultiSelectNotSupportedForWorktrees string NothingToStageForSubmodule string CommandDoesNotSupportOpeningInEditor string CustomCommands string @@ -1268,6 +1287,24 @@ func EnglishTranslationSet() *TranslationSet { MergeConflictTakeCurrentCommit: "Take current commit", MergeConflictTakeIncomingCommit: "Take incoming commit", SubmoduleMergeConflictDescription: "Conflict: the submodule '{{.path}}' was set to a different commit in the current and the incoming changes. Pick which commit to keep.", + SubmoduleLabel: "submodule", + RemovedSubmoduleLabel: "removed submodule", + NestedRepositoryLabel: "nested repository", + WorktreeLabel: "worktree", + RemovedSubmoduleDescription: "'{{.path}}' is left over from a submodule that this repository no longer has. A commit that you pulled or checked out probably removed it. Git leaves the files of a removed submodule behind, so they show up as an untracked directory.\n\nIf you don't need them any more, press {{.discardKey}} to discard the directory. This also discards any uncommitted changes in it; see the status below. The submodule's commits stay in its git directory, so checking out an older commit that still has the submodule keeps working.", + NestedRepositoryDescription: "'{{.path}}' is a git repository of its own inside this one. Git doesn't look into it, so it shows up as a single untracked entry.\n\nIf this repository is meant to track it, add it as a submodule. Otherwise, you can ignore it with {{.ignoreKey}} so that it no longer shows up here.", + NestedRepositoryDiscardHint: "Pressing {{.discardKey}} discards the directory, including any uncommitted changes in it; see the status below.", + NestedRepositoryDiscardWarning: "Pressing {{.discardKey}} discards the directory, including the repository with all of its history.", + WorktreeInsideRepoDescription: "'{{.path}}' is a linked worktree of this repository. Git doesn't look into it, so it shows up as a single untracked entry.\n\nTo remove the worktree, press {{.removeKey}}. If you keep worktrees inside the repository, add their directory to .gitignore so that they don't show up here.", + NestedRepositoryStatus: "Status of '{{.path}}'", + StageNestedRepositoryDisabled: "Can't stage '{{.path}}' because it is a git repository of its own. Discard it with {{.discardKey}}, or ignore it with {{.ignoreKey}}.", + StageWorktreeDisabled: "Can't stage '{{.path}}' because it is a worktree. Remove it with {{.removeKey}}, or ignore it with {{.ignoreKey}}.", + CheckingNestedRepositories: "Checking for uncommitted changes", + DiscardNestedRepositoriesPrompt: "Discarding this also discards the following. Are you sure?", + NestedRepositoryHistoryWouldBeLost: "'{{.path}}': the repository with all of its history", + NestedRepositoryChangesWouldBeLost: "'{{.path}}': uncommitted changes", + PlainDirectoryDescription: "'{{.path}}' is an untracked directory. Git shows it as a single entry because status.showUntrackedFiles is set to \"normal\".", + PlainDirectoryFiles: "Files in '{{.path}}'", StageConflictsRangeDisabled: "Cannot stage a selection that includes files with merge conflicts; resolve them individually with {{.goIntoKey}} first.", Checkout: "Checkout", CheckoutTooltip: "Checkout selected item.", @@ -2191,6 +2228,7 @@ func EnglishTranslationSet() *TranslationSet { SelectedItemIsNotABranch: "Selected item is not a branch", SelectedItemDoesNotHaveFiles: "Selected item does not have files to view", MultiSelectNotSupportedForSubmodules: "Multiselection not supported for submodules", + MultiSelectNotSupportedForWorktrees: "Multiselection not supported for worktrees", NothingToStageForSubmodule: "Nothing to stage: the parent repo can only stage a new submodule commit, not the uncommitted changes inside a submodule. Commit inside the submodule first.", CommandDoesNotSupportOpeningInEditor: "This command doesn't support switching to the editor", CustomCommands: "Custom commands", diff --git a/pkg/integration/tests/commit/commit_all_with_nested_repository.go b/pkg/integration/tests/commit/commit_all_with_nested_repository.go new file mode 100644 index 000000000..497498d68 --- /dev/null +++ b/pkg/integration/tests/commit/commit_all_with_nested_repository.go @@ -0,0 +1,52 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CommitAllWithNestedRepository = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Committing with nothing staged doesn't offer to stage all files if that would stage a nested repository", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CreateFile("file", "content") + shell.RunCommand([]string{"git", "init", "nested"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? file").IsSelected(), + Equals("?? nested (nested repository)"), + ). + Press(keys.Files.CommitChanges). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Equals("Can't stage 'nested' because it is a git repository of its own. Discard it with d, or ignore it with i.")). + Confirm() + }). + // Once something is staged, committing works as usual + PressPrimaryAction(). + Press(keys.Files.CommitChanges). + Tap(func() { + t.ExpectPopup().CommitMessagePanel(). + Type("add file"). + Confirm() + }). + Lines( + Equals("?? nested (nested repository)").IsSelected(), + ) + + t.Views().Commits(). + Lines( + Contains("add file"), + Contains("first commit"), + ) + }, +}) diff --git a/pkg/integration/tests/file/discard_nested_repositories.go b/pkg/integration/tests/file/discard_nested_repositories.go new file mode 100644 index 000000000..b286a263f --- /dev/null +++ b/pkg/integration/tests/file/discard_nested_repositories.go @@ -0,0 +1,69 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardNestedRepositories = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discarding a nested repository asks first if that would discard its history or changes that it has that aren't committed", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("sub", "modules/sub") + shell.Commit("add submodule") + shell.RunCommand([]string{"git", "rm", "--cached", "modules/sub"}) + shell.RunCommand([]string{"git", "rm", ".gitmodules"}) + shell.Commit("remove submodule") + shell.CreateFile("modules/sub/new-file", "content") + + shell.RunCommand([]string{"git", "init", "nested"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? modules/sub (removed submodule)").IsSelected(), + Equals("?? nested (nested repository)"), + ). + Press(keys.Universal.RangeSelectDown). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Discard changes")). + Select(Contains("Discard all changes")). + Confirm() + + t.ExpectPopup().Confirmation(). + Title(Equals("Discard changes")). + Content(Equals("Discarding this also discards the following. Are you sure?\n\n" + + "- 'modules/sub': uncommitted changes\n" + + "- 'nested': the repository with all of its history")). + Cancel() + }). + Lines( + Equals("?? modules/sub (removed submodule)").IsSelected(), + Equals("?? nested (nested repository)").IsSelected(), + ). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Discard changes")). + Select(Contains("Discard all changes")). + Confirm() + + t.ExpectPopup().Confirmation(). + Title(Equals("Discard changes")). + Content(Contains("Are you sure?")). + Confirm() + }). + IsEmpty() + + t.FileSystem().PathNotPresent("modules/sub") + t.FileSystem().PathNotPresent("nested") + }, +}) diff --git a/pkg/integration/tests/file/explain_nested_repositories.go b/pkg/integration/tests/file/explain_nested_repositories.go new file mode 100644 index 000000000..b837401e9 --- /dev/null +++ b/pkg/integration/tests/file/explain_nested_repositories.go @@ -0,0 +1,69 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ExplainNestedRepositories = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The main view explains what a removed submodule, a nested repository and a worktree inside the repo are, and shows their status", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("sub", "modules/sub") + shell.Commit("add submodule") + shell.RunCommand([]string{"git", "rm", "--cached", "modules/sub"}) + shell.RunCommand([]string{"git", "rm", ".gitmodules"}) + shell.Commit("remove submodule") + shell.CreateFile("modules/sub/new-file", "content") + + shell.RunCommand([]string{"git", "init", "nested"}) + + shell.AddWorktree("master", "worktree", "newbranch") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? modules/sub (removed submodule)").IsSelected(), + Equals("?? nested (nested repository)"), + Equals("?? worktree (worktree)"), + ) + + t.Views().Main(). + Content( + Contains("'modules/sub' is left over from a submodule"). + Contains("press d to discard the directory"). + Contains("── Status of 'modules/sub' ─"). + Contains("On branch master"). + Contains("new-file"), + ) + + t.Views().Files(). + NavigateToLine(Contains("nested")) + + t.Views().Main(). + Content( + Contains("'nested' is a git repository of its own"). + Contains("ignore it with i"). + Contains("of its history."). + Contains("── Status of 'nested' ─"). + Contains("No commits yet"), + ) + + t.Views().Files(). + NavigateToLine(Contains("worktree")) + + t.Views().Main(). + Content( + Contains("'worktree' is a linked worktree"). + Contains("press d."). + Contains("── Status of 'worktree' ─"). + Contains("On branch newbranch"), + ) + }, +}) diff --git a/pkg/integration/tests/file/explain_untracked_directory.go b/pkg/integration/tests/file/explain_untracked_directory.go new file mode 100644 index 000000000..f9d1f5acf --- /dev/null +++ b/pkg/integration/tests/file/explain_untracked_directory.go @@ -0,0 +1,34 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ExplainUntrackedDirectory = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "With status.showUntrackedFiles set to normal, the main view explains why an untracked directory is a single entry, and lists the files in it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.SetConfig("status.showUntrackedFiles", "normal") + shell.CreateFile("dir/file1", "content") + shell.CreateFile("dir/subdir/file2", "content") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? dir/").IsSelected(), + ) + + t.Views().Main(). + Content( + Contains("'dir' is an untracked directory"). + Contains("── Files in 'dir' ─"). + Contains("dir/file1"). + Contains("dir/subdir/file2"), + ) + }, +}) diff --git a/pkg/integration/tests/file/stage_nested_repository.go b/pkg/integration/tests/file/stage_nested_repository.go new file mode 100644 index 000000000..7776af656 --- /dev/null +++ b/pkg/integration/tests/file/stage_nested_repository.go @@ -0,0 +1,51 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageNestedRepository = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Staging a nested repository, or all files while there is one, is not supported, because git would add it as an embedded repository", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("sub", "modules/sub") + shell.Commit("add submodule") + shell.RunCommand([]string{"git", "rm", "--cached", "modules/sub"}) + shell.RunCommand([]string{"git", "rm", ".gitmodules"}) + shell.Commit("remove submodule") + + shell.CreateFile("file", "content") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? file").IsSelected(), + Equals("?? modules/sub (removed submodule)"), + ). + Press(keys.Files.ToggleStagedAll). + Tap(func() { + t.ExpectToast(Equals("Disabled: Can't stage 'modules/sub' because it is a git repository of its own. Discard it with d, or ignore it with i.")) + }). + NavigateToLine(Contains("modules/sub")). + PressPrimaryAction(). + Tap(func() { + t.ExpectToast(Equals("Disabled: Can't stage 'modules/sub' because it is a git repository of its own. Discard it with d, or ignore it with i.")) + }). + // Staging all files works if the nested repository is filtered out + FilterOrSearch("file"). + Lines( + Equals("?? file").IsSelected(), + ). + Press(keys.Files.ToggleStagedAll). + Lines( + Equals("A file").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/submodule/discard_removed_submodule.go b/pkg/integration/tests/submodule/discard_removed_submodule.go new file mode 100644 index 000000000..62bb969a6 --- /dev/null +++ b/pkg/integration/tests/submodule/discard_removed_submodule.go @@ -0,0 +1,44 @@ +package submodule + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardRemovedSubmodule = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discard the directory that a removed submodule leaves behind", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("sub", "modules/sub") + shell.Commit("add submodule") + + // This is what checking out a commit that removes the submodule does: + // the submodule is gone from the index and from .gitmodules, but git + // leaves its directory behind. + shell.RunCommand([]string{"git", "rm", "--cached", "modules/sub"}) + shell.RunCommand([]string{"git", "rm", ".gitmodules"}) + shell.Commit("remove submodule") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("▼ modules").IsSelected(), + Equals(" ?? sub (removed submodule)"), + ). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Discard changes")). + Select(Contains("Discard all changes")). + Confirm() + }). + IsFocused(). + IsEmpty() + + t.FileSystem().PathNotPresent("modules/sub") + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 76b8b3357..36b71ea00 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -114,6 +114,7 @@ var tests = []*components.IntegrationTest{ commit.CheckoutFileFromRangeSelectionOfCommits, commit.CheckoutFileWithLocalModifications, commit.Commit, + commit.CommitAllWithNestedRepository, commit.CommitMultiline, commit.CommitSkipHooks, commit.CommitSwitchToEditor, @@ -244,6 +245,7 @@ var tests = []*components.IntegrationTest{ file.DirectoryDiffWithRenamedFiles, file.DiscardAllDirChanges, file.DiscardAllDirChangesWhenFiltering, + file.DiscardNestedRepositories, file.DiscardRangeSelect, file.DiscardStagedChanges, file.DiscardUnstagedDirChanges, @@ -253,6 +255,8 @@ var tests = []*components.IntegrationTest{ file.DiscardVariousChanges, file.DiscardVariousChangesRangeSelect, file.ExcludeWithoutInfoDir, + file.ExplainNestedRepositories, + file.ExplainUntrackedDirectory, file.Gitignore, file.GitignoreSpecialCharacters, file.PaneShownAgainStartsAtTheTop, @@ -264,6 +268,7 @@ var tests = []*components.IntegrationTest{ file.StageAllWithoutChangedFiles, file.StageChildrenRangeSelect, file.StageDeletedRangeSelect, + file.StageNestedRepository, file.StageRangeSelect, file.StagedChangesInLowerPane, filter_and_search.FilterByFileStatus, @@ -569,6 +574,7 @@ var tests = []*components.IntegrationTest{ status.LogCmd, status.LogCmdStatusPanelAllBranchesLog, submodule.Add, + submodule.DiscardRemovedSubmodule, submodule.Enter, submodule.EnterFromDotfileBareRepo, submodule.EnterNested, @@ -695,6 +701,7 @@ var tests = []*components.IntegrationTest{ worktree.CustomCommand, worktree.DefaultPathTilde, worktree.DetachWorktreeFromBranch, + worktree.DiscardRangeWithWorktree, worktree.DotfileBareRepo, worktree.DoubleNestedLinkedSubmodule, worktree.EditFileOfBranchInOtherWorktree, diff --git a/pkg/integration/tests/worktree/discard_range_with_worktree.go b/pkg/integration/tests/worktree/discard_range_with_worktree.go new file mode 100644 index 000000000..064918479 --- /dev/null +++ b/pkg/integration/tests/worktree/discard_range_with_worktree.go @@ -0,0 +1,35 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardRangeWithWorktree = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discarding a range of files that includes a worktree inside the repo is not supported, because a worktree has its own menu for removing it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.AddWorktree("mybranch", "nested-worktree", "newbranch") + shell.CreateFile("untracked-file", "content") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? nested-worktree (worktree)").IsSelected(), + Equals("?? untracked-file"), + ). + Press(keys.Universal.RangeSelectDown). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectToast(Equals("Disabled: Multiselection not supported for worktrees")) + }) + }, +}) diff --git a/pkg/integration/tests/worktree/worktree_in_repo.go b/pkg/integration/tests/worktree/worktree_in_repo.go index f6b5b44a3..d05fc9136 100644 --- a/pkg/integration/tests/worktree/worktree_in_repo.go +++ b/pkg/integration/tests/worktree/worktree_in_repo.go @@ -64,18 +64,18 @@ var WorktreeInRepo = NewIntegrationTest(NewIntegrationTestArgs{ Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Menu(). - Title(Equals("Discard changes")). - Select(Contains("Discard all changes")). + Title(Equals("Remove worktree 'linked-worktree'?")). + Select(MatchesRegexp("Remove worktree$")). Confirm() }). IsEmpty() - // confirm worktree appears as missing + t.FileSystem().PathNotPresent("linked-worktree") + t.Views().Worktrees(). Focus(). Lines( Contains("(main worktree)").IsSelected(), - Contains("linked-worktree (missing)"), ) }, }) diff --git a/pkg/integration/tests/worktree/worktree_inside_repo.go b/pkg/integration/tests/worktree/worktree_inside_repo.go index bae2ff8f1..6e4b09854 100644 --- a/pkg/integration/tests/worktree/worktree_inside_repo.go +++ b/pkg/integration/tests/worktree/worktree_inside_repo.go @@ -22,7 +22,7 @@ var WorktreeInsideRepo = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Files(). IsFocused(). Lines( - Equals("?? 󰌹 nested-worktree").IsSelected(), + Equals("?? 󰌹 nested-worktree (worktree)").IsSelected(), ) }, })