mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-11-06 13:00:59 -04:00
Ask before discarding a nested repository if that loses anything
Discarding a nested repository deletes its directory like that of any untracked file. If the repository keeps its git dir in its directory, this deletes all of its history, and nothing said so. The directory of a removed submodule keeps its git dir elsewhere, but it may have changes that aren't committed. Check for both before discarding. If anything would be lost, list it and ask for confirmation. 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
9009083f55
commit
a75b0457c1
@@ -433,6 +433,19 @@ func (self *WorkingTreeCommands) NestedRepositoryHasOwnGitDir(path string) bool
|
||||
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 {
|
||||
|
||||
@@ -1879,19 +1879,21 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error {
|
||||
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(
|
||||
@@ -1905,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(
|
||||
@@ -1940,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 {
|
||||
|
||||
@@ -117,6 +117,10 @@ type TranslationSet struct {
|
||||
PlainDirectoryFiles string
|
||||
StageNestedRepositoryDisabled string
|
||||
StageWorktreeDisabled string
|
||||
CheckingNestedRepositories string
|
||||
DiscardNestedRepositoriesPrompt string
|
||||
NestedRepositoryHistoryWouldBeLost string
|
||||
NestedRepositoryChangesWouldBeLost string
|
||||
StageConflictsRangeDisabled string
|
||||
Checkout string
|
||||
CheckoutTooltip string
|
||||
@@ -1295,6 +1299,10 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
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.",
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
})
|
||||
@@ -245,6 +245,7 @@ var tests = []*components.IntegrationTest{
|
||||
file.DirectoryDiffWithRenamedFiles,
|
||||
file.DiscardAllDirChanges,
|
||||
file.DiscardAllDirChangesWhenFiltering,
|
||||
file.DiscardNestedRepositories,
|
||||
file.DiscardRangeSelect,
|
||||
file.DiscardStagedChanges,
|
||||
file.DiscardUnstagedDirChanges,
|
||||
|
||||
Reference in New Issue
Block a user