Explain the directories that git status reports as a whole

Selecting one of these in the files panel showed the error "Could not
access 'modules/sub/null'". The main view showed the entry's diff
against /dev/null with --no-index, as for an untracked file. When git
compares a file with a directory that way, it looks for a file of the
same name inside the directory.

Show what the directory is and what to do about it instead. Below that,
show the status of the repository that it is, or the files in it if it
is a plain directory. The status tells you whether discarding the
directory would lose anything.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-10-15 20:51:34 +02:00
co-authored by Claude Opus 5.5
parent d8d11434a4
commit 35909f65d8
7 changed files with 222 additions and 0 deletions
+31
View File
@@ -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);
+58
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"
@@ -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) {
@@ -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(
+16
View File
@@ -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.",
@@ -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"),
)
},
})
+2
View File
@@ -253,6 +253,8 @@ var tests = []*components.IntegrationTest{
file.DiscardVariousChanges,
file.DiscardVariousChangesRangeSelect,
file.ExcludeWithoutInfoDir,
file.ExplainNestedRepositories,
file.ExplainUntrackedDirectory,
file.Gitignore,
file.GitignoreSpecialCharacters,
file.PaneShownAgainStartsAtTheTop,