jesseduffield.lazygit/pkg/commands/git_commands/submodule.go
Stefan Haller 785c8a712c Explain when a submodule has nothing stageable
A submodule that only has dirty or untracked content (no new commit) can't
be staged from the parent repo, but it still shows up as having unstaged
changes. Pressing stage on it therefore briefly flashed as staged and then
reverted, without explaining why nothing was staged.

Detect this case (via `git submodule status`, where a '+' prefix marks a
stageable commit change) in the shared stage/unstage decision: if the only
thing that looks stageable is such a submodule, don't try to stage it.
Instead unstage if there's anything staged to unstage, so the toggle stays
symmetric; otherwise show an error explaining that there's nothing to stage.

Because the decision is shared, this covers both the stage (space) and
stage-all (a) keybindings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:12:09 +02:00

312 lines
7.9 KiB
Go

package git_commands
import (
"bufio"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/samber/lo"
)
// .gitmodules looks like this:
// [submodule "mysubmodule"]
// path = blah/mysubmodule
// url = git@github.com:subbo.git
type SubmoduleCommands struct {
*GitCommon
}
func NewSubmoduleCommands(gitCommon *GitCommon) *SubmoduleCommands {
return &SubmoduleCommands{
GitCommon: gitCommon,
}
}
func (self *SubmoduleCommands) GetConfigs(parentModule *models.SubmoduleConfig) ([]*models.SubmoduleConfig, error) {
gitModulesPath := ".gitmodules"
if parentModule != nil {
gitModulesPath = filepath.Join(parentModule.FullPath(), gitModulesPath)
}
file, err := os.Open(gitModulesPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanLines)
firstMatch := func(str string, regex string) (string, bool) {
re := regexp.MustCompile(regex)
matches := re.FindStringSubmatch(str)
if len(matches) > 0 {
return matches[1], true
}
return "", false
}
configs := []*models.SubmoduleConfig{}
lastConfigIdx := -1
for scanner.Scan() {
line := scanner.Text()
if name, ok := firstMatch(line, `\[submodule "(.*)"\]`); ok {
configs = append(configs, &models.SubmoduleConfig{
Name: name, ParentModule: parentModule,
})
lastConfigIdx = len(configs) - 1
continue
}
if lastConfigIdx != -1 {
if path, ok := firstMatch(line, `\s*path\s*=\s*(.*)\s*`); ok {
configs[lastConfigIdx].Path = path
nestedConfigs, err := self.GetConfigs(configs[lastConfigIdx])
if err == nil {
configs = append(configs, nestedConfigs...)
}
} else if url, ok := firstMatch(line, `\s*url\s*=\s*(.*)\s*`); ok {
configs[lastConfigIdx].Url = url
}
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return configs, nil
}
// AnyHaveStageableChanges reports whether any of the given submodule paths has
// a checked-out commit that differs from the one recorded in the
// superproject's index, i.e. a change that `git add <path>` would actually
// stage. A submodule that only has dirty or untracked content (with no new
// commit) can't be staged from the superproject, so it won't be reported here.
func (self *SubmoduleCommands) AnyHaveStageableChanges(paths []string) (bool, error) {
if len(paths) == 0 {
return false, nil
}
cmdArgs := NewGitCmd("submodule").Arg("status", "--").Arg(paths...).ToArgv()
output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
if err != nil {
return false, err
}
// Each line looks like "<prefix><sha> <path> (<describe>)". A '+' prefix
// means the checked-out commit differs from the index, i.e. there's a
// commit change to stage.
return lo.SomeBy(strings.Split(output, "\n"), func(line string) bool {
return strings.HasPrefix(line, "+")
}), nil
}
func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
// if the path does not exist then it hasn't yet been initialized so we'll swallow the error
// because the intention here is to have no dirty worktree state
if _, err := os.Stat(submodule.Path); os.IsNotExist(err) {
self.Log.Infof("submodule path %s does not exist, returning", submodule.FullPath())
return nil
}
cmdArgs := NewGitCmd("stash").
Dir(submodule.FullPath()).
Arg("--include-untracked").
ToArgv()
return self.cmd.New(cmdArgs).Run()
}
func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error {
parentDir := ""
if submodule.ParentModule != nil {
parentDir = submodule.ParentModule.FullPath()
}
cmdArgs := NewGitCmd("submodule").
Arg("update", "--init", "--force", "--", submodule.Path).
DirIf(parentDir != "", parentDir).
ToArgv()
return self.cmd.New(cmdArgs).Run()
}
func (self *SubmoduleCommands) UpdateAll() error {
// not doing an --init here because the user probably doesn't want that
cmdArgs := NewGitCmd("submodule").Arg("update", "--force").ToArgv()
return self.cmd.New(cmdArgs).Run()
}
func (self *SubmoduleCommands) Delete(submodule *models.SubmoduleConfig) error {
// based on https://gist.github.com/myusuf3/7f645819ded92bda6677
if submodule.ParentModule != nil {
wd, err := os.Getwd()
if err != nil {
return err
}
err = os.Chdir(submodule.ParentModule.FullPath())
if err != nil {
return err
}
defer func() { _ = os.Chdir(wd) }()
}
if err := self.cmd.New(
NewGitCmd("submodule").
Arg("deinit", "--force", "--", submodule.Path).ToArgv(),
).Run(); err != nil {
if !strings.Contains(err.Error(), "did not match any file(s) known to git") {
return err
}
if err := self.cmd.New(
NewGitCmd("config").
Arg("--file", ".gitmodules", "--remove-section", "submodule."+submodule.Path).
ToArgv(),
).Run(); err != nil {
return err
}
if err := self.cmd.New(
NewGitCmd("config").
Arg("--remove-section", "submodule."+submodule.Path).
ToArgv(),
).Run(); err != nil {
return err
}
}
if err := self.cmd.New(
NewGitCmd("rm").Arg("--force", "-r", submodule.Path).ToArgv(),
).Run(); err != nil {
// if the directory isn't there then that's fine
self.Log.Error(err)
}
// We may in fact want to use the repo's git dir path but git docs say not to
// mix submodules and worktrees anyway.
return os.RemoveAll(submodule.GitDirPath(self.repoPaths.repoGitDirPath))
}
func (self *SubmoduleCommands) Add(name string, path string, url string) error {
cmdArgs := NewGitCmd("submodule").
Arg("add").
Arg("--force").
Arg("--name").
Arg(name).
Arg("--").
Arg(url).
Arg(path).
ToArgv()
return self.cmd.New(cmdArgs).Run()
}
func (self *SubmoduleCommands) UpdateUrl(submodule *models.SubmoduleConfig, newUrl string) error {
if submodule.ParentModule != nil {
wd, err := os.Getwd()
if err != nil {
return err
}
err = os.Chdir(submodule.ParentModule.FullPath())
if err != nil {
return err
}
defer func() { _ = os.Chdir(wd) }()
}
setUrlCmdStr := NewGitCmd("config").
Arg(
"--file", ".gitmodules", "submodule."+submodule.Name+".url", newUrl,
).
ToArgv()
// the set-url command is only for later git versions so we're doing it manually here
if err := self.cmd.New(setUrlCmdStr).Run(); err != nil {
return err
}
syncCmdStr := NewGitCmd("submodule").Arg("sync", "--", submodule.Path).
ToArgv()
if err := self.cmd.New(syncCmdStr).Run(); err != nil {
return err
}
return nil
}
func (self *SubmoduleCommands) Init(path string) error {
cmdArgs := NewGitCmd("submodule").Arg("init", "--", path).
ToArgv()
return self.cmd.New(cmdArgs).Run()
}
func (self *SubmoduleCommands) Update(path string) error {
cmdArgs := NewGitCmd("submodule").Arg("update", "--init", "--", path).
ToArgv()
return self.cmd.New(cmdArgs).Run()
}
func (self *SubmoduleCommands) BulkInitCmdObj() *oscommands.CmdObj {
cmdArgs := NewGitCmd("submodule").Arg("init").
ToArgv()
return self.cmd.New(cmdArgs)
}
func (self *SubmoduleCommands) BulkUpdateCmdObj() *oscommands.CmdObj {
cmdArgs := NewGitCmd("submodule").Arg("update").
ToArgv()
return self.cmd.New(cmdArgs)
}
func (self *SubmoduleCommands) ForceBulkUpdateCmdObj() *oscommands.CmdObj {
cmdArgs := NewGitCmd("submodule").Arg("update", "--force").
ToArgv()
return self.cmd.New(cmdArgs)
}
func (self *SubmoduleCommands) BulkUpdateRecursivelyCmdObj() *oscommands.CmdObj {
cmdArgs := NewGitCmd("submodule").Arg("update", "--init", "--recursive").
ToArgv()
return self.cmd.New(cmdArgs)
}
func (self *SubmoduleCommands) BulkDeinitCmdObj() *oscommands.CmdObj {
cmdArgs := NewGitCmd("submodule").Arg("deinit", "--all", "--force").
ToArgv()
return self.cmd.New(cmdArgs)
}
func (self *SubmoduleCommands) ResetSubmodules(submodules []*models.SubmoduleConfig) error {
for _, submodule := range submodules {
if err := self.Stash(submodule); err != nil {
return err
}
}
return self.UpdateAll()
}