mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-11-06 13:00:59 -04:00
Improve display and handling of deleted submodules and nested repos/worktrees (#6134)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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}
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
})
|
||||
@@ -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(),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user