mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-28 10:15:32 -05:00
GIT_DIR and GIT_WORK_TREE tell git where our repo is, and every command we run inherits them — including the ones we point at a submodule or another worktree. git resolves those against our repo instead, and says nothing about it: with GIT_DIR set, `git -C mysub log -1` reports the superproject's commit. So opening lazygit with --git-dir/--work-tree quietly broke resolving submodule conflicts, stashing and resetting a submodule, and detaching another worktree; the worktree list came back claiming every worktree shared our git dir. Drop the two variables from the commands that address another repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
371 lines
12 KiB
Go
371 lines
12 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) {
|
|
// Resolve the path against the repo this commands object was created for
|
|
// rather than the process working directory, so that a read from a
|
|
// still-running refresh keeps addressing that repo after the user
|
|
// switched to another one.
|
|
dir := self.repoPaths.WorktreePath()
|
|
if parentModule != nil {
|
|
dir = filepath.Join(dir, parentModule.FullPath())
|
|
}
|
|
gitModulesPath := filepath.Join(dir, ".gitmodules")
|
|
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
|
|
}
|
|
|
|
// GetConflictCommits returns the three gitlink commits of a conflicted submodule
|
|
// from the index: the merge base, our (current) commit, and their (incoming)
|
|
// commit. Any of them can be empty if that stage is absent (e.g. a submodule
|
|
// that was added on only one side). The path is relative to the repo root.
|
|
func (self *SubmoduleCommands) GetConflictCommits(path string) (base string, ours string, theirs string, err error) {
|
|
cmdArgs := NewGitCmd("ls-files").Arg("-u", "-z", "--", path).ToArgv()
|
|
output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
|
|
// Each NUL-terminated entry looks like "<mode> <sha> <stage>\t<path>".
|
|
for _, entry := range strings.Split(output, "\x00") {
|
|
// fields are split on the tab and the spaces, so the leading three are
|
|
// always mode, sha, stage regardless of what the path contains.
|
|
fields := strings.Fields(entry)
|
|
if len(fields) < 3 {
|
|
continue
|
|
}
|
|
switch fields[2] {
|
|
case "1":
|
|
base = fields[1]
|
|
case "2":
|
|
ours = fields[1]
|
|
case "3":
|
|
theirs = fields[1]
|
|
}
|
|
}
|
|
|
|
return base, ours, theirs, nil
|
|
}
|
|
|
|
// GetCommitSummary returns "<short-sha> <subject>" for a commit inside the
|
|
// submodule at the given path, for display in the conflict menu.
|
|
func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string, error) {
|
|
cmdArgs := NewGitCmd("log").
|
|
Dir(path).
|
|
Arg("--format=%h %s", "--max-count=1", sha).
|
|
Config("log.showsignature=false").
|
|
ToArgv()
|
|
|
|
summary, err := forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
|
|
return strings.TrimSpace(summary), err
|
|
}
|
|
|
|
// CheckoutConflictCommit resolves a submodule conflict by checking the submodule
|
|
// out at the given commit. `git checkout --ours/--theirs` is a no-op on
|
|
// gitlinks, so we check out the chosen commit in the submodule itself; the
|
|
// caller then stages the submodule to record the resolution.
|
|
func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) error {
|
|
cmdArgs := NewGitCmd("checkout").Dir(path).Arg(sha).ToArgv()
|
|
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
|
|
}
|
|
|
|
// ConflictSideLog returns a oneline log, run inside the submodule, of the commits
|
|
// that `side` has but `otherSide` does not (i.e. `otherSide..side`) — the commits
|
|
// unique to one side of a commit conflict, relative to their common ancestor. It
|
|
// is empty if `side` is an ancestor of `otherSide` (e.g. that side was rewound).
|
|
func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSide string) (string, error) {
|
|
cmdArgs := NewGitCmd("log").Dir(path).
|
|
Arg("--oneline", "--color=always", otherSide+".."+side).
|
|
ToArgv()
|
|
|
|
return forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
|
|
}
|
|
|
|
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(filepath.Join(self.repoPaths.WorktreePath(), submodule.FullPath())); 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 forOtherRepo(self.cmd.New(cmdArgs)).Run()
|
|
}
|
|
|
|
func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error {
|
|
cmdArgs := NewGitCmd("submodule").
|
|
Arg("update", "--init", "--force", "--", submodule.Path).
|
|
ToArgv()
|
|
|
|
return self.runInParentModule(submodule, self.cmd.New(cmdArgs))
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
// runInParentModule runs the given command in the submodule's parent module's
|
|
// directory when the submodule is nested: its path arguments (and the
|
|
// .gitmodules file the config commands touch) are relative to the parent
|
|
// module. The directory is set on the command itself rather than by
|
|
// temporarily chdir-ing the process there, which would leak the parent
|
|
// module's directory into whatever other commands run concurrently (e.g. a
|
|
// background refresh's).
|
|
//
|
|
// That directory is relative, so it resolves against the process working
|
|
// directory rather than against the repo directory the command builder
|
|
// otherwise pins commands to. Only foreground commands the user issued end up
|
|
// here, and lazygit won't switch repos while one of those is in flight, so the
|
|
// two are the same directory; don't call this from background work, where they
|
|
// need not be.
|
|
func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error {
|
|
if submodule.ParentModule != nil {
|
|
forOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath()))
|
|
}
|
|
return cmdObj.Run()
|
|
}
|
|
|
|
func (self *SubmoduleCommands) Delete(submodule *models.SubmoduleConfig) error {
|
|
// based on https://gist.github.com/myusuf3/7f645819ded92bda6677
|
|
|
|
if err := self.runInParentModule(submodule, self.cmd.New(
|
|
NewGitCmd("submodule").
|
|
Arg("deinit", "--force", "--", submodule.Path).ToArgv(),
|
|
)); err != nil {
|
|
if !strings.Contains(err.Error(), "did not match any file(s) known to git") {
|
|
return err
|
|
}
|
|
|
|
if err := self.runInParentModule(submodule, self.cmd.New(
|
|
NewGitCmd("config").
|
|
Arg("--file", ".gitmodules", "--remove-section", "submodule."+submodule.Path).
|
|
ToArgv(),
|
|
)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := self.runInParentModule(submodule, self.cmd.New(
|
|
NewGitCmd("config").
|
|
Arg("--remove-section", "submodule."+submodule.Path).
|
|
ToArgv(),
|
|
)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := self.runInParentModule(submodule, self.cmd.New(
|
|
NewGitCmd("rm").Arg("--force", "-r", submodule.Path).ToArgv(),
|
|
)); 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 {
|
|
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.runInParentModule(submodule, self.cmd.New(setUrlCmdStr)); err != nil {
|
|
return err
|
|
}
|
|
|
|
syncCmdStr := NewGitCmd("submodule").Arg("sync", "--", submodule.Path).
|
|
ToArgv()
|
|
|
|
if err := self.runInParentModule(submodule, self.cmd.New(syncCmdStr)); 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()
|
|
}
|