Improve display and handling of deleted submodules and nested repos/worktrees (#6134)

This commit is contained in:
Stefan Haller
2026-10-15 21:29:34 +02:00
committed by GitHub
26 changed files with 1026 additions and 163 deletions
+47 -8
View File
@@ -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).
@@ -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
}
+31
View File
@@ -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
}
+44
View File
@@ -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);
+29
View File
@@ -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
+1 -1
View File
@@ -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}
})
+230 -33
View File
@@ -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 {
+62 -2
View File
@@ -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 {
+11 -13
View File
@@ -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
@@ -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))
})
+1 -32
View File
@@ -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
}
@@ -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 {
+1 -57
View File
@@ -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 {
+33 -5
View File
@@ -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
+19 -1
View File
@@ -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)
})
}
+38
View File
@@ -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",
@@ -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"),
)
},
})
@@ -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")
},
})
@@ -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"),
)
},
})
@@ -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"),
)
},
})
@@ -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(),
)
},
})
@@ -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")
},
})
+7
View File
@@ -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,
@@ -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"))
})
},
})
@@ -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)"),
)
},
})
@@ -22,7 +22,7 @@ var WorktreeInsideRepo = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Files().
IsFocused().
Lines(
Equals("?? 󰌹 nested-worktree").IsSelected(),
Equals("?? 󰌹 nested-worktree (worktree)").IsSelected(),
)
},
})