diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 8ebff5910..09c86a882 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -412,6 +412,37 @@ 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() +} + +// UntrackedFilesCmdObj returns the command that lists the untracked files in +// the given directory, for the main view +func (self *WorkingTreeCommands) UntrackedFilesCmdObj(dirPath string) *oscommands.CmdObj { + cmdArgs := NewGitCmd("ls-files"). + Arg("--others", "--exclude-standard", "--", dirPath). + ToArgv() + + return self.cmd.New(cmdArgs).DontLog() +} + // ShowFileDiff get the diff of specified from and to. Typically this will be used for a single commit so it'll be 123abc^..123abc // but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode. // For a renamed file, previousPath is the path it was renamed from (empty otherwise); diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 9b2b91f59..e4c42279b 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -9,6 +9,7 @@ import ( "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/filetree" @@ -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) { diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 1cdc99606..32582d675 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -119,6 +119,18 @@ func (self *WorkingTreeHelper) WorktreeForFile(file *models.File) (*models.Workt 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(), + } +} + func (self *WorkingTreeHelper) OpenMergeTool() error { self.c.LogAction(self.c.Tr.Actions.OpenMergeTool) return self.c.RunSubprocessAndRefresh( diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 81dd2c335..da7774b80 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -107,6 +107,14 @@ type TranslationSet struct { RemovedSubmoduleLabel string NestedRepositoryLabel string WorktreeLabel string + RemovedSubmoduleDescription string + NestedRepositoryDescription string + NestedRepositoryDiscardHint string + NestedRepositoryDiscardWarning string + WorktreeInsideRepoDescription string + NestedRepositoryStatus string + PlainDirectoryDescription string + PlainDirectoryFiles string StageConflictsRangeDisabled string Checkout string CheckoutTooltip string @@ -1277,6 +1285,14 @@ func EnglishTranslationSet() *TranslationSet { 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}}'", + 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.", diff --git a/pkg/integration/tests/file/explain_nested_repositories.go b/pkg/integration/tests/file/explain_nested_repositories.go new file mode 100644 index 000000000..b837401e9 --- /dev/null +++ b/pkg/integration/tests/file/explain_nested_repositories.go @@ -0,0 +1,69 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ExplainNestedRepositories = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The main view explains what a removed submodule, a nested repository and a worktree inside the repo are, and shows their status", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("sub", "modules/sub") + shell.Commit("add submodule") + shell.RunCommand([]string{"git", "rm", "--cached", "modules/sub"}) + shell.RunCommand([]string{"git", "rm", ".gitmodules"}) + shell.Commit("remove submodule") + shell.CreateFile("modules/sub/new-file", "content") + + shell.RunCommand([]string{"git", "init", "nested"}) + + shell.AddWorktree("master", "worktree", "newbranch") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? modules/sub (removed submodule)").IsSelected(), + Equals("?? nested (nested repository)"), + Equals("?? worktree (worktree)"), + ) + + t.Views().Main(). + Content( + Contains("'modules/sub' is left over from a submodule"). + Contains("press d to discard the directory"). + Contains("── Status of 'modules/sub' ─"). + Contains("On branch master"). + Contains("new-file"), + ) + + t.Views().Files(). + NavigateToLine(Contains("nested")) + + t.Views().Main(). + Content( + Contains("'nested' is a git repository of its own"). + Contains("ignore it with i"). + Contains("of its history."). + Contains("── Status of 'nested' ─"). + Contains("No commits yet"), + ) + + t.Views().Files(). + NavigateToLine(Contains("worktree")) + + t.Views().Main(). + Content( + Contains("'worktree' is a linked worktree"). + Contains("press d."). + Contains("── Status of 'worktree' ─"). + Contains("On branch newbranch"), + ) + }, +}) diff --git a/pkg/integration/tests/file/explain_untracked_directory.go b/pkg/integration/tests/file/explain_untracked_directory.go new file mode 100644 index 000000000..f9d1f5acf --- /dev/null +++ b/pkg/integration/tests/file/explain_untracked_directory.go @@ -0,0 +1,34 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ExplainUntrackedDirectory = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "With status.showUntrackedFiles set to normal, the main view explains why an untracked directory is a single entry, and lists the files in it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.SetConfig("status.showUntrackedFiles", "normal") + shell.CreateFile("dir/file1", "content") + shell.CreateFile("dir/subdir/file2", "content") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? dir/").IsSelected(), + ) + + t.Views().Main(). + Content( + Contains("'dir' is an untracked directory"). + Contains("── Files in 'dir' ─"). + Contains("dir/file1"). + Contains("dir/subdir/file2"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 8e8e27aec..003ec0f03 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -253,6 +253,8 @@ var tests = []*components.IntegrationTest{ file.DiscardVariousChanges, file.DiscardVariousChangesRangeSelect, file.ExcludeWithoutInfoDir, + file.ExplainNestedRepositories, + file.ExplainUntrackedDirectory, file.Gitignore, file.GitignoreSpecialCharacters, file.PaneShownAgainStartsAtTheTop,