mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-11-12 15:53:33 -04:00
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:
co-authored by
Claude Opus 5.5
parent
d8d11434a4
commit
35909f65d8
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -253,6 +253,8 @@ var tests = []*components.IntegrationTest{
|
||||
file.DiscardVariousChanges,
|
||||
file.DiscardVariousChangesRangeSelect,
|
||||
file.ExcludeWithoutInfoDir,
|
||||
file.ExplainNestedRepositories,
|
||||
file.ExplainUntrackedDirectory,
|
||||
file.Gitignore,
|
||||
file.GitignoreSpecialCharacters,
|
||||
file.PaneShownAgainStartsAtTheTop,
|
||||
|
||||
Reference in New Issue
Block a user