Compare commits
38 Commits
release-no
...
gh-integra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79a72fe7a6 | ||
|
|
ac8e76bee0 | ||
|
|
c06974b879 | ||
|
|
b5578b3816 | ||
|
|
93bbea1221 | ||
|
|
f277ea2350 | ||
|
|
4a31f3fd50 | ||
|
|
c3d2722cb8 | ||
|
|
ed4e7ac960 | ||
|
|
bf355bc0b5 | ||
|
|
27a12fe2ea | ||
|
|
37aa558d34 | ||
|
|
588bd95118 | ||
|
|
2b8c959cb0 | ||
|
|
4fead59416 | ||
|
|
2d1c29fa76 | ||
|
|
66f3978ab5 | ||
|
|
c8de14aa9d | ||
|
|
d2e75a6359 | ||
|
|
b36f6db521 | ||
|
|
769a6cd069 | ||
|
|
1547b4155e | ||
|
|
153970fb08 | ||
|
|
aed7c29509 | ||
|
|
4a1fa4889d | ||
|
|
515e2d2fa9 | ||
|
|
c1f3fd6b3f | ||
|
|
9d774c2450 | ||
|
|
04edfc2c63 | ||
|
|
1bf9d2e30c | ||
|
|
502c23dff7 | ||
|
|
61d08dc4af | ||
|
|
a71db3355a | ||
|
|
7fbf4fa7f7 | ||
|
|
3ed1611d94 | ||
|
|
ada3b528d3 | ||
|
|
3df068b1fc | ||
|
|
c295deaa81 |
@@ -85,6 +85,7 @@ git:
|
||||
overrideGpg: false # prevents lazygit from spawning a separate process when using GPG
|
||||
disableForcePushing: false
|
||||
parseEmoji: false
|
||||
enableGhCommand: false
|
||||
diffContextSize: 3 # how many lines of context are shown around a change in diffs
|
||||
os:
|
||||
editCommand: '' # see 'Configuring File Editing' section
|
||||
|
||||
@@ -112,22 +112,63 @@ func NewApp(config config.AppConfigurer, common *common.Common) (*App, error) {
|
||||
if err != nil {
|
||||
return app, err
|
||||
}
|
||||
|
||||
if app.Gui.Config.GetUserConfig().Git.EnableGhCommand {
|
||||
if err := app.validateGhVersion(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (app *App) validateGhVersion() error {
|
||||
output, err := app.OSCommand.Cmd.New("gh --version").RunWithOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf(app.Tr.FailedToObtainGhVersionError, err.Error())
|
||||
}
|
||||
|
||||
if !isGhVersionValid(output) {
|
||||
return errors.New(app.Tr.MinGhVersionError)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isGhVersionValid(versionStr string) bool {
|
||||
// output should be something like:
|
||||
// gh version 2.0.0 (2021-08-23)
|
||||
// https://github.com/cli/cli/releases/tag/v2.0.0
|
||||
re := regexp.MustCompile(`[^\d]+([\d\.]+)`)
|
||||
matches := re.FindStringSubmatch(versionStr)
|
||||
|
||||
if len(matches) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
ghVersion := matches[1]
|
||||
majorVersion, err := strconv.Atoi(ghVersion[0:1])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if majorVersion < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (app *App) validateGitVersion() error {
|
||||
output, err := app.OSCommand.Cmd.New("git --version").RunWithOutput()
|
||||
// if we get an error anywhere here we'll show the same status
|
||||
minVersionError := errors.New(app.Tr.MinGitVersionError)
|
||||
if err != nil {
|
||||
return minVersionError
|
||||
return fmt.Errorf(app.Tr.FailedToObtainGitVersionError, err.Error())
|
||||
}
|
||||
|
||||
if isGitVersionValid(output) {
|
||||
return nil
|
||||
if !isGitVersionValid(output) {
|
||||
return errors.New(app.Tr.MinGitVersionError)
|
||||
}
|
||||
|
||||
return minVersionError
|
||||
return nil
|
||||
}
|
||||
|
||||
func isGitVersionValid(versionStr string) bool {
|
||||
|
||||
@@ -43,3 +43,43 @@ func TestIsGitVersionValid(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidGhVersion(t *testing.T) {
|
||||
type scenario struct {
|
||||
versionStr string
|
||||
expectedResult bool
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
"",
|
||||
false,
|
||||
},
|
||||
{
|
||||
`gh version 1.0.0 (2020-08-23)
|
||||
https://github.com/cli/cli/releases/tag/v1.0.0`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
`gh version 2.0.0 (2021-08-23)
|
||||
https://github.com/cli/cli/releases/tag/v2.0.0`,
|
||||
true,
|
||||
},
|
||||
{
|
||||
`gh version 1.1.0 (2021-10-14)
|
||||
https://github.com/cli/cli/releases/tag/v1.1.0
|
||||
|
||||
A new release of gh is available: 1.1.0 → v2.2.0
|
||||
To upgrade, run: brew update && brew upgrade gh
|
||||
https://github.com/cli/cli/releases/tag/v2.2.0`,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.versionStr, func(t *testing.T) {
|
||||
result := isGhVersionValid(s.versionStr)
|
||||
assert.Equal(t, result, s.expectedResult)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,22 +22,24 @@ import (
|
||||
|
||||
// GitCommand is our main git interface
|
||||
type GitCommand struct {
|
||||
Branch *git_commands.BranchCommands
|
||||
Commit *git_commands.CommitCommands
|
||||
Config *git_commands.ConfigCommands
|
||||
Custom *git_commands.CustomCommands
|
||||
File *git_commands.FileCommands
|
||||
Flow *git_commands.FlowCommands
|
||||
Patch *git_commands.PatchCommands
|
||||
Rebase *git_commands.RebaseCommands
|
||||
Remote *git_commands.RemoteCommands
|
||||
Stash *git_commands.StashCommands
|
||||
Status *git_commands.StatusCommands
|
||||
Submodule *git_commands.SubmoduleCommands
|
||||
Sync *git_commands.SyncCommands
|
||||
Tag *git_commands.TagCommands
|
||||
WorkingTree *git_commands.WorkingTreeCommands
|
||||
Bisect *git_commands.BisectCommands
|
||||
Branch *git_commands.BranchCommands
|
||||
Commit *git_commands.CommitCommands
|
||||
Config *git_commands.ConfigCommands
|
||||
Custom *git_commands.CustomCommands
|
||||
File *git_commands.FileCommands
|
||||
Flow *git_commands.FlowCommands
|
||||
Patch *git_commands.PatchCommands
|
||||
Rebase *git_commands.RebaseCommands
|
||||
Remote *git_commands.RemoteCommands
|
||||
Stash *git_commands.StashCommands
|
||||
Status *git_commands.StatusCommands
|
||||
Submodule *git_commands.SubmoduleCommands
|
||||
Sync *git_commands.SyncCommands
|
||||
Tag *git_commands.TagCommands
|
||||
WorkingTree *git_commands.WorkingTreeCommands
|
||||
Bisect *git_commands.BisectCommands
|
||||
Gh *git_commands.GhCommands
|
||||
HostingService *git_commands.HostingService
|
||||
|
||||
Loaders Loaders
|
||||
}
|
||||
@@ -119,24 +121,28 @@ func NewGitCommandAux(
|
||||
patchManager := patch.NewPatchManager(cmn.Log, workingTreeCommands.ApplyPatch, workingTreeCommands.ShowFileDiff)
|
||||
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchManager)
|
||||
bisectCommands := git_commands.NewBisectCommands(gitCommon)
|
||||
ghCommands := git_commands.NewGhCommand(gitCommon)
|
||||
hostingServiceCommands := git_commands.NewHostingServiceCommand(gitCommon)
|
||||
|
||||
return &GitCommand{
|
||||
Branch: branchCommands,
|
||||
Commit: commitCommands,
|
||||
Config: configCommands,
|
||||
Custom: customCommands,
|
||||
File: fileCommands,
|
||||
Flow: flowCommands,
|
||||
Patch: patchCommands,
|
||||
Rebase: rebaseCommands,
|
||||
Remote: remoteCommands,
|
||||
Stash: stashCommands,
|
||||
Status: statusCommands,
|
||||
Submodule: submoduleCommands,
|
||||
Sync: syncCommands,
|
||||
Tag: tagCommands,
|
||||
Bisect: bisectCommands,
|
||||
WorkingTree: workingTreeCommands,
|
||||
Branch: branchCommands,
|
||||
Commit: commitCommands,
|
||||
Config: configCommands,
|
||||
Custom: customCommands,
|
||||
File: fileCommands,
|
||||
Flow: flowCommands,
|
||||
Patch: patchCommands,
|
||||
Rebase: rebaseCommands,
|
||||
Remote: remoteCommands,
|
||||
Stash: stashCommands,
|
||||
Status: statusCommands,
|
||||
Submodule: submoduleCommands,
|
||||
Sync: syncCommands,
|
||||
Tag: tagCommands,
|
||||
Bisect: bisectCommands,
|
||||
WorkingTree: workingTreeCommands,
|
||||
Gh: ghCommands,
|
||||
HostingService: hostingServiceCommands,
|
||||
Loaders: Loaders{
|
||||
Branches: loaders.NewBranchLoader(cmn, branchCommands.GetRawBranches, branchCommands.CurrentBranchName, configCommands),
|
||||
CommitFiles: loaders.NewCommitFileLoader(cmn, cmd),
|
||||
|
||||
146
pkg/commands/git_commands/gh.go
Normal file
146
pkg/commands/git_commands/gh.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package git_commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
)
|
||||
|
||||
type GhCommands struct {
|
||||
*GitCommon
|
||||
}
|
||||
|
||||
func NewGhCommand(gitCommon *GitCommon) *GhCommands {
|
||||
return &GhCommands{
|
||||
GitCommon: gitCommon,
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/cli/cli/issues/2300
|
||||
func (self *GhCommands) BaseRepo() error {
|
||||
return self.cmd.New("git config --local --get-regexp .gh-resolved").Run()
|
||||
}
|
||||
|
||||
// Ex: git config --local --add "remote.origin.gh-resolved" "jesseduffield/lazygit"
|
||||
func (self *GhCommands) SetBaseRepo(repository string) (string, error) {
|
||||
return self.cmd.New(
|
||||
fmt.Sprintf("git config --local --add \"remote.origin.gh-resolved\" \"%s\"", repository),
|
||||
).RunWithOutput()
|
||||
}
|
||||
|
||||
func (self *GhCommands) prList() (string, error) {
|
||||
return self.cmd.New(
|
||||
"gh pr list --limit 500 --state all --json state,url,number,headRefName,headRepositoryOwner",
|
||||
).RunWithOutput()
|
||||
}
|
||||
|
||||
func (self *GhCommands) GithubMostRecentPRs() ([]*models.GithubPullRequest, error) {
|
||||
commandOutput, err := self.prList()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prs := []*models.GithubPullRequest{}
|
||||
err = json.Unmarshal([]byte(commandOutput), &prs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return prs, nil
|
||||
}
|
||||
|
||||
func GenerateGithubPullRequestMap(prs []*models.GithubPullRequest, branches []*models.Branch, remotes []*models.Remote) map[*models.Branch]*models.GithubPullRequest {
|
||||
res := map[*models.Branch]*models.GithubPullRequest{}
|
||||
|
||||
if len(prs) == 0 {
|
||||
return res
|
||||
}
|
||||
|
||||
remotesToOwnersMap := getRemotesToOwnersMap(remotes)
|
||||
|
||||
if len(remotesToOwnersMap) == 0 {
|
||||
return res
|
||||
}
|
||||
|
||||
// A PR can be identified by two things: the owner e.g. 'jesseduffield' and the
|
||||
// branch name e.g. 'feature/my-feature'. The owner might be different
|
||||
// to the owner of the repo if the PR is from a fork of that repo.
|
||||
type prKey struct {
|
||||
owner string
|
||||
branchName string
|
||||
}
|
||||
|
||||
prByKey := map[prKey]models.GithubPullRequest{}
|
||||
|
||||
for _, pr := range prs {
|
||||
prByKey[prKey{owner: pr.UserName(), branchName: pr.BranchName()}] = *pr
|
||||
}
|
||||
|
||||
for _, branch := range branches {
|
||||
if !branch.IsTrackingRemote() {
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO: support branches whose UpstreamRemote contains a full git
|
||||
// URL rather than just a remote name.
|
||||
owner, foundRemoteOwner := remotesToOwnersMap[branch.UpstreamRemote]
|
||||
if !foundRemoteOwner {
|
||||
continue
|
||||
}
|
||||
|
||||
pr, hasPr := prByKey[prKey{owner: owner, branchName: branch.UpstreamBranch}]
|
||||
|
||||
if !hasPr {
|
||||
continue
|
||||
}
|
||||
|
||||
res[branch] = &pr
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func getRemotesToOwnersMap(remotes []*models.Remote) map[string]string {
|
||||
res := map[string]string{}
|
||||
for _, remote := range remotes {
|
||||
if len(remote.Urls) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
res[remote.Name] = GetRepoInfoFromURL(remote.Urls[0]).Owner
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
type RepoInformation struct {
|
||||
Owner string
|
||||
Repository string
|
||||
}
|
||||
|
||||
// TODO: move this into hosting_service.go
|
||||
func GetRepoInfoFromURL(url string) RepoInformation {
|
||||
isHTTP := strings.HasPrefix(url, "http")
|
||||
|
||||
if isHTTP {
|
||||
splits := strings.Split(url, "/")
|
||||
owner := strings.Join(splits[3:len(splits)-1], "/")
|
||||
repo := strings.TrimSuffix(splits[len(splits)-1], ".git")
|
||||
|
||||
return RepoInformation{
|
||||
Owner: owner,
|
||||
Repository: repo,
|
||||
}
|
||||
}
|
||||
|
||||
tmpSplit := strings.Split(url, ":")
|
||||
splits := strings.Split(tmpSplit[1], "/")
|
||||
owner := strings.Join(splits[0:len(splits)-1], "/")
|
||||
repo := strings.TrimSuffix(splits[len(splits)-1], ".git")
|
||||
|
||||
return RepoInformation{
|
||||
Owner: owner,
|
||||
Repository: repo,
|
||||
}
|
||||
}
|
||||
34
pkg/commands/git_commands/hosting_service.go
Normal file
34
pkg/commands/git_commands/hosting_service.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package git_commands
|
||||
|
||||
import "github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
|
||||
|
||||
// a hosting service is something like github, gitlab, bitbucket etc
|
||||
type HostingService struct {
|
||||
*GitCommon
|
||||
}
|
||||
|
||||
func NewHostingServiceCommand(gitCommon *GitCommon) *HostingService {
|
||||
return &HostingService{
|
||||
GitCommon: gitCommon,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *HostingService) GetPullRequestURL(from string, to string) (string, error) {
|
||||
return self.getHostingServiceMgr(self.config.GetRemoteURL()).GetPullRequestURL(from, to)
|
||||
}
|
||||
|
||||
func (self *HostingService) GetCommitURL(commitSha string) (string, error) {
|
||||
return self.getHostingServiceMgr(self.config.GetRemoteURL()).GetCommitURL(commitSha)
|
||||
}
|
||||
|
||||
func (self *HostingService) GetRepoNameFromRemoteURL(remoteURL string) (string, error) {
|
||||
return self.getHostingServiceMgr(remoteURL).GetRepoName()
|
||||
}
|
||||
|
||||
// getting this on every request rather than storing it in state in case our remoteURL changes
|
||||
// from one invocation to the next. Note however that we're currently caching config
|
||||
// results so we might want to invalidate the cache here if it becomes a problem.
|
||||
func (self *HostingService) getHostingServiceMgr(remoteURL string) *hosting_service.HostingServiceMgr {
|
||||
configServices := self.UserConfig.Services
|
||||
return hosting_service.NewHostingServiceMgr(self.Log, self.Tr, remoteURL, configServices)
|
||||
}
|
||||
@@ -6,7 +6,11 @@ var defaultUrlRegexStrings = []string{
|
||||
`^(?:https?|ssh)://[^/]+/(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
|
||||
`^git@.*:(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
|
||||
}
|
||||
var defaultRepoURLTemplate = "https://{{.webDomain}}/{{.owner}}/{{.repo}}"
|
||||
|
||||
var (
|
||||
defaultRepoURLTemplate = "https://{{.webDomain}}/{{.owner}}/{{.repo}}"
|
||||
defaultRepoNameTemplate = "{{.owner}}/{{.repo}}"
|
||||
)
|
||||
|
||||
// we've got less type safety using go templates but this lends itself better to
|
||||
// users adding custom service definitions in their config
|
||||
@@ -17,6 +21,7 @@ var githubServiceDef = ServiceDefinition{
|
||||
commitURL: "/commit/{{.CommitSha}}",
|
||||
regexStrings: defaultUrlRegexStrings,
|
||||
repoURLTemplate: defaultRepoURLTemplate,
|
||||
repoNameTemplate: defaultRepoNameTemplate,
|
||||
}
|
||||
|
||||
var bitbucketServiceDef = ServiceDefinition{
|
||||
@@ -28,7 +33,8 @@ var bitbucketServiceDef = ServiceDefinition{
|
||||
`^(?:https?|ssh)://.*/(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
|
||||
`^.*@.*:(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
|
||||
},
|
||||
repoURLTemplate: defaultRepoURLTemplate,
|
||||
repoURLTemplate: defaultRepoURLTemplate,
|
||||
repoNameTemplate: defaultRepoNameTemplate,
|
||||
}
|
||||
|
||||
var gitLabServiceDef = ServiceDefinition{
|
||||
@@ -38,6 +44,7 @@ var gitLabServiceDef = ServiceDefinition{
|
||||
commitURL: "/commit/{{.CommitSha}}",
|
||||
regexStrings: defaultUrlRegexStrings,
|
||||
repoURLTemplate: defaultRepoURLTemplate,
|
||||
repoNameTemplate: defaultRepoNameTemplate,
|
||||
}
|
||||
|
||||
var azdoServiceDef = ServiceDefinition{
|
||||
@@ -50,6 +57,8 @@ var azdoServiceDef = ServiceDefinition{
|
||||
`^https://.*@dev.azure.com/(?P<org>.*?)/(?P<project>.*?)/_git/(?P<repo>.*?)(?:\.git)?$`,
|
||||
},
|
||||
repoURLTemplate: "https://{{.webDomain}}/{{.org}}/{{.project}}/_git/{{.repo}}",
|
||||
// TODO: verify this is actually correct
|
||||
repoNameTemplate: "{{.org}}/{{.project}}/{{.repo}}",
|
||||
}
|
||||
|
||||
var bitbucketServerServiceDef = ServiceDefinition{
|
||||
@@ -62,6 +71,8 @@ var bitbucketServerServiceDef = ServiceDefinition{
|
||||
`^https://.*/scm/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`,
|
||||
},
|
||||
repoURLTemplate: "https://{{.webDomain}}/projects/{{.project}}/repos/{{.repo}}",
|
||||
// TODO: verify this is actually correct
|
||||
repoNameTemplate: "{{.project}}/{{.repo}}",
|
||||
}
|
||||
|
||||
var serviceDefinitions = []ServiceDefinition{
|
||||
|
||||
@@ -61,6 +61,18 @@ func (self *HostingServiceMgr) GetCommitURL(commitSha string) (string, error) {
|
||||
return pullRequestURL, nil
|
||||
}
|
||||
|
||||
// e.g. 'jesseduffield/lazygit'
|
||||
func (self *HostingServiceMgr) GetRepoName() (string, error) {
|
||||
gitService, err := self.getService()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
repoName := gitService.repoName
|
||||
|
||||
return repoName, nil
|
||||
}
|
||||
|
||||
func (self *HostingServiceMgr) getService() (*Service, error) {
|
||||
serviceDomain, err := self.getServiceDomain(self.remoteURL)
|
||||
if err != nil {
|
||||
@@ -72,8 +84,14 @@ func (self *HostingServiceMgr) getService() (*Service, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repoName, err := serviceDomain.serviceDefinition.getRepoNameFromRemoteURL(self.remoteURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Service{
|
||||
repoURL: repoURL,
|
||||
repoName: repoName,
|
||||
ServiceDefinition: serviceDomain.serviceDefinition,
|
||||
}, nil
|
||||
}
|
||||
@@ -147,24 +165,45 @@ type ServiceDefinition struct {
|
||||
regexStrings []string
|
||||
|
||||
// can expect 'webdomain' to be passed in. Otherwise, you get to pick what we match in the regex
|
||||
repoURLTemplate string
|
||||
repoURLTemplate string
|
||||
repoNameTemplate string
|
||||
}
|
||||
|
||||
func (self ServiceDefinition) getRepoURLFromRemoteURL(url string, webDomain string) (string, error) {
|
||||
matches, err := self.parseRemoteUrl(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
matches["webDomain"] = webDomain
|
||||
return utils.ResolvePlaceholderString(self.repoURLTemplate, matches), nil
|
||||
}
|
||||
|
||||
func (self ServiceDefinition) getRepoNameFromRemoteURL(url string) (string, error) {
|
||||
matches, err := self.parseRemoteUrl(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return utils.ResolvePlaceholderString(self.repoNameTemplate, matches), nil
|
||||
}
|
||||
|
||||
func (self ServiceDefinition) parseRemoteUrl(url string) (map[string]string, error) {
|
||||
for _, regexStr := range self.regexStrings {
|
||||
re := regexp.MustCompile(regexStr)
|
||||
input := utils.FindNamedMatches(re, url)
|
||||
if input != nil {
|
||||
input["webDomain"] = webDomain
|
||||
return utils.ResolvePlaceholderString(self.repoURLTemplate, input), nil
|
||||
matches := utils.FindNamedMatches(re, url)
|
||||
if matches != nil {
|
||||
return matches, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("Failed to parse repo information from url")
|
||||
return nil, errors.New("Failed to parse repo information from url")
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repoURL string
|
||||
// e.g. 'jesseduffield/lazygit'
|
||||
repoName string
|
||||
ServiceDefinition
|
||||
}
|
||||
|
||||
|
||||
25
pkg/commands/models/github.go
Normal file
25
pkg/commands/models/github.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
type GithubPullRequest struct {
|
||||
HeadRefName string `json:"headRefName"`
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"` // "MERGED", "OPEN", "CLOSED"
|
||||
Url string `json:"url"`
|
||||
HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"`
|
||||
}
|
||||
|
||||
func (pr *GithubPullRequest) UserName() string {
|
||||
// e.g. 'jesseduffield'
|
||||
return pr.HeadRepositoryOwner.Login
|
||||
}
|
||||
|
||||
func (pr *GithubPullRequest) BranchName() string {
|
||||
// e.g. 'feature/my-feature'
|
||||
return pr.HeadRefName
|
||||
}
|
||||
|
||||
type GithubRepositoryOwner struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Login string `json:"login"`
|
||||
}
|
||||
@@ -85,6 +85,7 @@ type GitConfig struct {
|
||||
// this should really be under 'gui', not 'git'
|
||||
ParseEmoji bool `yaml:"parseEmoji"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
EnableGhCommand bool `yaml:"enableGhCommand"`
|
||||
DiffContextSize int `yaml:"diffContextSize"`
|
||||
}
|
||||
|
||||
@@ -403,6 +404,7 @@ func GetDefaultConfig() *UserConfig {
|
||||
CommitPrefixes: map[string]CommitPrefixConfig(nil),
|
||||
ParseEmoji: false,
|
||||
DiffContextSize: 3,
|
||||
EnableGhCommand: false,
|
||||
},
|
||||
Refresher: RefresherConfig{
|
||||
RefreshInterval: 10,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package gui
|
||||
|
||||
import "github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
func (gui *Gui) branchesRenderToMain() error {
|
||||
var task types.UpdateTask
|
||||
|
||||
@@ -23,10 +23,9 @@ func (gui *Gui) resetControllers() {
|
||||
)
|
||||
|
||||
rebaseHelper := helpers.NewMergeAndRebaseHelper(helperCommon, gui.State.Contexts, gui.git, refsHelper)
|
||||
suggestionsHelper := helpers.NewSuggestionsHelper(helperCommon, model, gui.refreshSuggestions)
|
||||
suggestionsHelper := helpers.NewSuggestionsHelper(helperCommon, gui.git, model, gui.refreshSuggestions)
|
||||
gui.helpers = &helpers.Helpers{
|
||||
Refs: refsHelper,
|
||||
Host: helpers.NewHostHelper(helperCommon, gui.git),
|
||||
PatchBuilding: helpers.NewPatchBuildingHelper(helperCommon, gui.git, gui.State.Contexts),
|
||||
Bisect: helpers.NewBisectHelper(helperCommon, gui.git),
|
||||
Suggestions: suggestionsHelper,
|
||||
|
||||
@@ -150,7 +150,7 @@ func (self *BasicCommitsController) copyCommitSHAToClipboard(commit *models.Comm
|
||||
}
|
||||
|
||||
func (self *BasicCommitsController) copyCommitURLToClipboard(commit *models.Commit) error {
|
||||
url, err := self.helpers.Host.GetCommitURL(commit.Sha)
|
||||
url, err := self.git.HostingService.GetCommitURL(commit.Sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -212,7 +212,7 @@ func (self *BasicCommitsController) copyCommitMessageToClipboard(commit *models.
|
||||
}
|
||||
|
||||
func (self *BasicCommitsController) openInBrowser(commit *models.Commit) error {
|
||||
url, err := self.helpers.Host.GetCommitURL(commit.Sha)
|
||||
url, err := self.git.HostingService.GetCommitURL(commit.Sha)
|
||||
if err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ func (self *BranchesController) copyPullRequestURL() error {
|
||||
return self.c.Error(errors.New(self.c.Tr.NoBranchOnRemote))
|
||||
}
|
||||
|
||||
url, err := self.helpers.Host.GetPullRequestURL(branch.Name, "")
|
||||
url, err := self.git.HostingService.GetPullRequestURL(branch.Name, "")
|
||||
if err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
@@ -462,7 +462,7 @@ func (self *BranchesController) createPullRequestMenu(selectedBranch *models.Bra
|
||||
}
|
||||
|
||||
func (self *BranchesController) createPullRequest(from string, to string) error {
|
||||
url, err := self.helpers.Host.GetPullRequestURL(from, to)
|
||||
url, err := self.git.HostingService.GetPullRequestURL(from, to)
|
||||
if err != nil {
|
||||
return self.c.Error(err)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ type Helpers struct {
|
||||
MergeAndRebase *MergeAndRebaseHelper
|
||||
MergeConflicts *MergeConflictsHelper
|
||||
CherryPick *CherryPickHelper
|
||||
Host *HostHelper
|
||||
PatchBuilding *PatchBuildingHelper
|
||||
GPG *GpgHelper
|
||||
Upstream *UpstreamHelper
|
||||
@@ -27,7 +26,6 @@ func NewStubHelpers() *Helpers {
|
||||
MergeAndRebase: &MergeAndRebaseHelper{},
|
||||
MergeConflicts: &MergeConflictsHelper{},
|
||||
CherryPick: &CherryPickHelper{},
|
||||
Host: &HostHelper{},
|
||||
PatchBuilding: &PatchBuildingHelper{},
|
||||
GPG: &GpgHelper{},
|
||||
Upstream: &UpstreamHelper{},
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
// this helper just wraps our hosting_service package
|
||||
|
||||
type IHostHelper interface {
|
||||
GetPullRequestURL(from string, to string) (string, error)
|
||||
GetCommitURL(commitSha string) (string, error)
|
||||
}
|
||||
|
||||
type HostHelper struct {
|
||||
c *types.HelperCommon
|
||||
git *commands.GitCommand
|
||||
}
|
||||
|
||||
func NewHostHelper(
|
||||
c *types.HelperCommon,
|
||||
git *commands.GitCommand,
|
||||
) *HostHelper {
|
||||
return &HostHelper{
|
||||
c: c,
|
||||
git: git,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *HostHelper) GetPullRequestURL(from string, to string) (string, error) {
|
||||
return self.getHostingServiceMgr().GetPullRequestURL(from, to)
|
||||
}
|
||||
|
||||
func (self *HostHelper) GetCommitURL(commitSha string) (string, error) {
|
||||
return self.getHostingServiceMgr().GetCommitURL(commitSha)
|
||||
}
|
||||
|
||||
// getting this on every request rather than storing it in state in case our remoteURL changes
|
||||
// from one invocation to the next. Note however that we're currently caching config
|
||||
// results so we might want to invalidate the cache here if it becomes a problem.
|
||||
func (self *HostHelper) getHostingServiceMgr() *hosting_service.HostingServiceMgr {
|
||||
remoteUrl := self.git.Config.GetRemoteURL()
|
||||
configServices := self.c.UserConfig.Services
|
||||
return hosting_service.NewHostingServiceMgr(self.c.Log, self.c.Tr, remoteUrl, configServices)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/jesseduffield/generics/slices"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
@@ -36,6 +37,7 @@ type SuggestionsHelper struct {
|
||||
c *types.HelperCommon
|
||||
|
||||
model *types.Model
|
||||
git *commands.GitCommand
|
||||
refreshSuggestionsFn func()
|
||||
}
|
||||
|
||||
@@ -43,11 +45,13 @@ var _ ISuggestionsHelper = &SuggestionsHelper{}
|
||||
|
||||
func NewSuggestionsHelper(
|
||||
c *types.HelperCommon,
|
||||
git *commands.GitCommand,
|
||||
model *types.Model,
|
||||
refreshSuggestionsFn func(),
|
||||
) *SuggestionsHelper {
|
||||
return &SuggestionsHelper{
|
||||
c: c,
|
||||
git: git,
|
||||
model: model,
|
||||
refreshSuggestionsFn: refreshSuggestionsFn,
|
||||
}
|
||||
@@ -80,6 +84,30 @@ func (self *SuggestionsHelper) getBranchNames() []string {
|
||||
})
|
||||
}
|
||||
|
||||
func (self *SuggestionsHelper) GetRemoteRepoSuggestionsFunc() func(string) []*types.Suggestion {
|
||||
remotesNames := self.getRemoteRepoNames()
|
||||
|
||||
return FuzzySearchFunc(remotesNames)
|
||||
}
|
||||
|
||||
func (self *SuggestionsHelper) getRemoteRepoNames() []string {
|
||||
remotes := self.model.Remotes
|
||||
result := make([]string, 0, len(remotes))
|
||||
for _, remote := range remotes {
|
||||
if len(remote.Urls) == 0 {
|
||||
continue
|
||||
}
|
||||
repoName, err := self.git.HostingService.GetRepoNameFromRemoteURL(remote.Urls[0])
|
||||
if err != nil {
|
||||
self.c.Log.Error(err)
|
||||
continue
|
||||
}
|
||||
result = append(result, repoName)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (self *SuggestionsHelper) GetBranchNameSuggestionsFunc() func(string) []*types.Suggestion {
|
||||
branchNames := self.getBranchNames()
|
||||
|
||||
|
||||
@@ -286,6 +286,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs, reuseState bool) {
|
||||
ReflogCommits: make([]*models.Commit, 0),
|
||||
BisectInfo: git_commands.NewNullBisectInfo(),
|
||||
FilesTrie: patricia.NewTrie(),
|
||||
PullRequests: make([]*models.GithubPullRequest, 0),
|
||||
},
|
||||
Modes: &types.Modes{
|
||||
Filtering: filtering.New(startArgs.FilterPath),
|
||||
@@ -773,3 +774,15 @@ func (gui *Gui) onUIThread(f func() error) {
|
||||
return f()
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) GetPr(branch *models.Branch) (*models.GithubPullRequest, bool, error) {
|
||||
prs := git_commands.GenerateGithubPullRequestMap(
|
||||
gui.State.Model.PullRequests,
|
||||
[]*models.Branch{branch},
|
||||
gui.State.Model.Remotes,
|
||||
)
|
||||
|
||||
pr, hasPr := prs[branch]
|
||||
|
||||
return pr, hasPr, nil
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ func (gui *Gui) branchesListContext() *context.BranchesContext {
|
||||
func() []*models.Branch { return gui.State.Model.Branches },
|
||||
gui.Views.Branches,
|
||||
func(startIdx int, length int) [][]string {
|
||||
return presentation.GetBranchListDisplayStrings(gui.State.Model.Branches, gui.State.ScreenMode != SCREEN_NORMAL, gui.State.Modes.Diffing.Ref, gui.Tr)
|
||||
prs := git_commands.GenerateGithubPullRequestMap(gui.State.Model.PullRequests, gui.State.Model.Branches, gui.State.Model.Remotes)
|
||||
return presentation.GetBranchListDisplayStrings(gui.State.Model.Branches, prs, gui.State.ScreenMode != SCREEN_NORMAL, gui.State.Modes.Diffing.Ref, gui.Tr)
|
||||
},
|
||||
nil,
|
||||
gui.withDiffModeCheck(gui.branchesRenderToMain),
|
||||
|
||||
@@ -2,6 +2,7 @@ package presentation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/generics/slices"
|
||||
@@ -15,15 +16,17 @@ import (
|
||||
|
||||
var branchPrefixColorCache = make(map[string]style.TextStyle)
|
||||
|
||||
func GetBranchListDisplayStrings(branches []*models.Branch, fullDescription bool, diffName string, tr *i18n.TranslationSet) [][]string {
|
||||
func GetBranchListDisplayStrings(branches []*models.Branch, prs map[*models.Branch]*models.GithubPullRequest, fullDescription bool, diffName string, tr *i18n.TranslationSet) [][]string {
|
||||
return slices.Map(branches, func(branch *models.Branch) []string {
|
||||
diffed := branch.Name == diffName
|
||||
return getBranchDisplayStrings(branch, fullDescription, diffed, tr)
|
||||
return getBranchDisplayStrings(branch, prs, fullDescription, diffed, tr)
|
||||
})
|
||||
}
|
||||
|
||||
// getBranchDisplayStrings returns the display string of branch
|
||||
func getBranchDisplayStrings(b *models.Branch, fullDescription bool, diffed bool, tr *i18n.TranslationSet) []string {
|
||||
func getBranchDisplayStrings(b *models.Branch,
|
||||
prs map[*models.Branch]*models.GithubPullRequest, fullDescription bool, diffed bool, tr *i18n.TranslationSet,
|
||||
) []string {
|
||||
displayName := b.Name
|
||||
if b.DisplayName != "" {
|
||||
displayName = b.DisplayName
|
||||
@@ -48,7 +51,11 @@ func getBranchDisplayStrings(b *models.Branch, fullDescription bool, diffed bool
|
||||
if icons.IsIconEnabled() {
|
||||
res = append(res, nameTextStyle.Sprint(icons.IconForBranch(b)))
|
||||
}
|
||||
res = append(res, coloredName)
|
||||
|
||||
pr, hasPr := prs[b]
|
||||
|
||||
res = append(res, coloredPrNumber(pr, hasPr), coloredName)
|
||||
|
||||
if fullDescription {
|
||||
res = append(
|
||||
res,
|
||||
@@ -124,3 +131,24 @@ func BranchStatus(branch *models.Branch, tr *i18n.TranslationSet) string {
|
||||
func SetCustomBranches(customBranchColors map[string]string) {
|
||||
branchPrefixColorCache = utils.SetCustomColors(customBranchColors)
|
||||
}
|
||||
|
||||
func coloredPrNumber(pr *models.GithubPullRequest, hasPr bool) string {
|
||||
if hasPr {
|
||||
return prColor(pr.State).Sprint("#" + strconv.Itoa(pr.Number))
|
||||
}
|
||||
|
||||
return ("")
|
||||
}
|
||||
|
||||
func prColor(state string) style.TextStyle {
|
||||
switch state {
|
||||
case "OPEN":
|
||||
return style.FgGreen
|
||||
case "CLOSED":
|
||||
return style.FgRed
|
||||
case "MERGED":
|
||||
return style.FgMagenta
|
||||
default:
|
||||
return style.FgDefault
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,21 +164,38 @@ func (gui *Gui) Refresh(options types.RefreshOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// during startup, the bottleneck is fetching the reflog entries. We need these
|
||||
// on startup to sort the branches by recency. So we have two phases: INITIAL, and COMPLETE.
|
||||
// In the initial phase we don't get any reflog commits, but we asynchronously get them
|
||||
// and refresh the branches after that
|
||||
func (gui *Gui) refreshReflogCommitsConsideringStartup() {
|
||||
// during startup, the bottleneck is fetching the reflog entries and Github PRs, both of which affect the contents of the branches view.
|
||||
// So we have two phases: INITIAL, and COMPLETE.
|
||||
// In the initial phase we asynchronously refresh the dependencies (reflog entries and Github PRs) and then refresh the branches view.
|
||||
// After that, we synchronously refresh the dependencies
|
||||
func (gui *Gui) refreshBranchDependenciesConsideringStartup() {
|
||||
refreshDeps := func() {
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
_ = gui.refreshReflogCommits()
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
_ = gui.refreshGithubPullRequests()
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
switch gui.State.StartupStage {
|
||||
case INITIAL:
|
||||
go utils.Safe(func() {
|
||||
_ = gui.refreshReflogCommits()
|
||||
refreshDeps()
|
||||
gui.refreshBranches()
|
||||
gui.State.StartupStage = COMPLETE
|
||||
})
|
||||
|
||||
case COMPLETE:
|
||||
_ = gui.refreshReflogCommits()
|
||||
refreshDeps()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +207,7 @@ func (gui *Gui) refreshCommits() {
|
||||
wg.Add(2)
|
||||
|
||||
go utils.Safe(func() {
|
||||
gui.refreshReflogCommitsConsideringStartup()
|
||||
gui.refreshBranchDependenciesConsideringStartup()
|
||||
|
||||
gui.refreshBranches()
|
||||
wg.Done()
|
||||
@@ -711,3 +728,61 @@ func (gui *Gui) refreshMergePanel(isFocused bool) error {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) refreshGithubPullRequests() error {
|
||||
if !gui.Config.GetUserConfig().Git.EnableGhCommand {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := gui.git.Gh.BaseRepo(); err == nil {
|
||||
if err := gui.setGithubPullRequests(); err != nil {
|
||||
return gui.c.Error(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// when config not exists
|
||||
err := gui.refreshRemotes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = gui.c.Prompt(types.PromptOpts{
|
||||
Title: gui.c.Tr.SelectRemoteRepository,
|
||||
InitialContent: "",
|
||||
FindSuggestionsFunc: gui.helpers.Suggestions.GetRemoteRepoSuggestionsFunc(),
|
||||
HandleConfirm: func(repository string) error {
|
||||
return gui.c.WithWaitingStatus(gui.c.Tr.LcSelectingRemote, func() error {
|
||||
// `repository` is something like 'jesseduffield/lazygit'
|
||||
_, err := gui.git.Gh.SetBaseRepo(repository)
|
||||
if err != nil {
|
||||
return gui.c.Error(err)
|
||||
}
|
||||
|
||||
if err := gui.setGithubPullRequests(); err != nil {
|
||||
return gui.c.Error(err)
|
||||
}
|
||||
|
||||
// calling refreshBranches explicitly because it may have taken
|
||||
// a while for the user to submit their response.
|
||||
gui.refreshBranches()
|
||||
|
||||
return nil
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) setGithubPullRequests() error {
|
||||
prs, err := gui.git.Gh.GithubMostRecentPRs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gui.State.Model.PullRequests = prs
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ type Model struct {
|
||||
StashEntries []*models.StashEntry
|
||||
SubCommits []*models.Commit
|
||||
Remotes []*models.Remote
|
||||
PullRequests []*models.GithubPullRequest
|
||||
|
||||
// FilteredReflogCommits are the ones that appear in the reflog panel.
|
||||
// when in filtering mode we only include the ones that match the given path
|
||||
|
||||
@@ -436,6 +436,8 @@ func chineseTranslationSet() TranslationSet {
|
||||
LcCopiedToClipboard: "复制到剪贴板",
|
||||
ErrCannotEditDirectory: "无法编辑目录:您只能编辑单个文件",
|
||||
ErrStageDirWithInlineMergeConflicts: "无法 暂存/取消暂存 包含具有内联合并冲突的文件的目录。请先解决合并冲突",
|
||||
SelectRemoteRepository: "选择存储库",
|
||||
LcSelectingRemote: "选择遥控器",
|
||||
ErrRepositoryMovedOrDeleted: "找不到仓库。它可能已被移动或删除 ¯\\_(ツ)_/¯",
|
||||
CommandLog: "命令日志",
|
||||
ToggleShowCommandLog: "切换 显示/隐藏 命令日志",
|
||||
|
||||
@@ -356,6 +356,8 @@ type TranslationSet struct {
|
||||
LcNextScreenMode string
|
||||
LcPrevScreenMode string
|
||||
LcStartSearch string
|
||||
SelectRemoteRepository string
|
||||
LcSelectingRemote string
|
||||
Panel string
|
||||
Keybindings string
|
||||
LcRenameBranch string
|
||||
@@ -418,6 +420,9 @@ type TranslationSet struct {
|
||||
LcBuildingPatch string
|
||||
LcViewCommits string
|
||||
MinGitVersionError string
|
||||
FailedToObtainGitVersionError string
|
||||
MinGhVersionError string
|
||||
FailedToObtainGhVersionError string
|
||||
LcRunningCustomCommandStatus string
|
||||
LcSubmoduleStashAndReset string
|
||||
LcAndResetSubmodules string
|
||||
@@ -996,6 +1001,8 @@ func EnglishTranslationSet() TranslationSet {
|
||||
LcNextScreenMode: "next screen mode (normal/half/fullscreen)",
|
||||
LcPrevScreenMode: "prev screen mode",
|
||||
LcStartSearch: "start search",
|
||||
LcSelectingRemote: "selecting remote",
|
||||
SelectRemoteRepository: "Select GitHub base remote repository (for PRs)",
|
||||
Panel: "Panel",
|
||||
Keybindings: "Keybindings",
|
||||
LcRenameBranch: "rename branch",
|
||||
@@ -1059,6 +1066,9 @@ func EnglishTranslationSet() TranslationSet {
|
||||
LcBuildingPatch: "building patch",
|
||||
LcViewCommits: "view commits",
|
||||
MinGitVersionError: "Git version must be at least 2.0 (i.e. from 2014 onwards). Please upgrade your git version. Alternatively raise an issue at https://github.com/jesseduffield/lazygit/issues for lazygit to be more backwards compatible.",
|
||||
MinGhVersionError: "GH version must be at least 2.0. Please upgrade your gh version. Alternatively raise an issue at https://github.com/jesseduffield/lazygit/issues for lazygit to be more backwards compatible.",
|
||||
FailedToObtainGitVersionError: "Failed to obtain git version. Output from running 'git --version' was: %s",
|
||||
FailedToObtainGhVersionError: "Failed to obtain gh version. Output from running 'gh --version' was: %s",
|
||||
LcRunningCustomCommandStatus: "running custom command",
|
||||
LcSubmoduleStashAndReset: "stash uncommitted submodule changes and update",
|
||||
LcAndResetSubmodules: "and reset submodules",
|
||||
|
||||
Reference in New Issue
Block a user