mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
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>
312 lines
7.9 KiB
Go
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()
|
|
}
|