mirror of
				https://github.com/jesseduffield/lazygit.git
				synced 2025-10-30 23:57:43 +02:00 
			
		
		
		
	Show github pull request status against branch
This commit is contained in:
		
				
					committed by
					
						 Stefan Haller
						Stefan Haller
					
				
			
			
				
	
			
			
			
						parent
						
							32a701cb9c
						
					
				
				
					commit
					55d2ac6fe7
				
			| @@ -481,6 +481,10 @@ git: | ||||
|   # to 40 to disable truncation. | ||||
|   truncateCopiedCommitHashesTo: 12 | ||||
|  | ||||
|   # If true and if if `gh` is installed and on version >=2, we will use `gh` to | ||||
|   # display pull requests against branches. | ||||
|   enableGithubCli: true | ||||
|  | ||||
| # Periodic update checks | ||||
| update: | ||||
|   # One of: 'prompt' (default) | 'background' | 'never' | ||||
|   | ||||
							
								
								
									
										6
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								go.mod
									
									
									
									
									
								
							| @@ -10,6 +10,7 @@ require ( | ||||
| 	github.com/adrg/xdg v0.4.0 | ||||
| 	github.com/atotto/clipboard v0.1.4 | ||||
| 	github.com/aybabtme/humanlog v0.4.1 | ||||
| 	github.com/cli/go-gh/v2 v2.9.0 | ||||
| 	github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 | ||||
| 	github.com/creack/pty v1.1.11 | ||||
| 	github.com/gdamore/tcell/v2 v2.9.0 | ||||
| @@ -50,6 +51,7 @@ require ( | ||||
| 	github.com/ProtonMail/go-crypto v1.1.6 // indirect | ||||
| 	github.com/bahlo/generic-list-go v0.2.0 // indirect | ||||
| 	github.com/buger/jsonparser v1.1.1 // indirect | ||||
| 	github.com/cli/safeexec v1.0.0 // indirect | ||||
| 	github.com/clipperhouse/uax29/v2 v2.2.0 // indirect | ||||
| 	github.com/cloudflare/circl v1.6.1 // indirect | ||||
| 	github.com/cyphar/filepath-securejoin v0.4.1 // indirect | ||||
| @@ -69,8 +71,8 @@ require ( | ||||
| 	github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect | ||||
| 	github.com/kylelemons/godebug v1.1.0 // indirect | ||||
| 	github.com/mailru/easyjson v0.7.7 // indirect | ||||
| 	github.com/mattn/go-colorable v0.1.11 // indirect | ||||
| 	github.com/mattn/go-isatty v0.0.14 // indirect | ||||
| 	github.com/mattn/go-colorable v0.1.13 // indirect | ||||
| 	github.com/mattn/go-isatty v0.0.20 // indirect | ||||
| 	github.com/onsi/ginkgo v1.10.3 // indirect | ||||
| 	github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect | ||||
| 	github.com/pjbgf/sha1cd v0.3.2 // indirect | ||||
|   | ||||
							
								
								
									
										17
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								go.sum
									
									
									
									
									
								
							| @@ -64,6 +64,10 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA | ||||
| github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= | ||||
| github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= | ||||
| github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= | ||||
| github.com/cli/go-gh/v2 v2.9.0 h1:D3lTjEneMYl54M+WjZ+kRPrR5CEJ5BHS05isBPOV3LI= | ||||
| github.com/cli/go-gh/v2 v2.9.0/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE= | ||||
| github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= | ||||
| github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= | ||||
| github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= | ||||
| github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= | ||||
| github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= | ||||
| @@ -233,13 +237,14 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 | ||||
| github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= | ||||
| github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= | ||||
| github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= | ||||
| github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs= | ||||
| github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= | ||||
| github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= | ||||
| github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= | ||||
| github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= | ||||
| github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= | ||||
| github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= | ||||
| github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= | ||||
| github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= | ||||
| github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= | ||||
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | ||||
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | ||||
| github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= | ||||
| github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= | ||||
| github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= | ||||
| @@ -479,13 +484,13 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w | ||||
| golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||
|   | ||||
| @@ -39,6 +39,8 @@ type GitCommand struct { | ||||
| 	Worktree    *git_commands.WorktreeCommands | ||||
| 	Version     *git_commands.GitVersion | ||||
| 	RepoPaths   *git_commands.RepoPaths | ||||
| 	GitHub         *git_commands.GitHubCommands | ||||
| 	HostingService *git_commands.HostingService | ||||
|  | ||||
| 	Loaders Loaders | ||||
| } | ||||
| @@ -137,6 +139,8 @@ func NewGitCommandAux( | ||||
| 	bisectCommands := git_commands.NewBisectCommands(gitCommon) | ||||
| 	worktreeCommands := git_commands.NewWorktreeCommands(gitCommon) | ||||
| 	blameCommands := git_commands.NewBlameCommands(gitCommon) | ||||
| 	gitHubCommands := git_commands.NewGitHubCommand(gitCommon) | ||||
| 	hostingServiceCommands := git_commands.NewHostingServiceCommand(gitCommon) | ||||
|  | ||||
| 	branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands) | ||||
| 	commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd) | ||||
| @@ -168,6 +172,8 @@ func NewGitCommandAux( | ||||
| 		WorkingTree: workingTreeCommands, | ||||
| 		Worktree:    worktreeCommands, | ||||
| 		Version:     version, | ||||
| 		GitHub:         gitHubCommands, | ||||
| 		HostingService: hostingServiceCommands, | ||||
| 		Loaders: Loaders{ | ||||
| 			BranchLoader:       branchLoader, | ||||
| 			CommitFileLoader:   commitFileLoader, | ||||
|   | ||||
							
								
								
									
										432
									
								
								pkg/commands/git_commands/github.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										432
									
								
								pkg/commands/git_commands/github.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,432 @@ | ||||
| package git_commands | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"regexp" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/cli/go-gh/v2/pkg/auth" | ||||
| 	gogit "github.com/jesseduffield/go-git/v5" | ||||
| 	"github.com/jesseduffield/lazygit/pkg/commands/models" | ||||
| 	"github.com/samber/lo" | ||||
| 	"golang.org/x/sync/errgroup" | ||||
| ) | ||||
|  | ||||
| type GitHubCommands struct { | ||||
| 	*GitCommon | ||||
| } | ||||
|  | ||||
| func NewGitHubCommand(gitCommon *GitCommon) *GitHubCommands { | ||||
| 	return &GitHubCommands{ | ||||
| 		GitCommon: gitCommon, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // https://github.com/cli/cli/issues/2300 | ||||
| func (self *GitHubCommands) BaseRepo() error { | ||||
| 	cmdArgs := NewGitCmd("config"). | ||||
| 		Arg("--local", "--get-regexp", ".gh-resolved"). | ||||
| 		ToArgv() | ||||
|  | ||||
| 	return self.cmd.New(cmdArgs).DontLog().Run() | ||||
| } | ||||
|  | ||||
| // Ex: git config --local --add "remote.origin.gh-resolved" "jesseduffield/lazygit" | ||||
| func (self *GitHubCommands) SetBaseRepo(repository string) (string, error) { | ||||
| 	cmdArgs := NewGitCmd("config"). | ||||
| 		Arg("--local", "--add", "remote.origin.gh-resolved", repository). | ||||
| 		ToArgv() | ||||
|  | ||||
| 	return self.cmd.New(cmdArgs).DontLog().RunWithOutput() | ||||
| } | ||||
|  | ||||
| type Response struct { | ||||
| 	Data RepositoryQuery `json:"data"` | ||||
| } | ||||
|  | ||||
| type RepositoryQuery struct { | ||||
| 	Repository map[string]PullRequest `json:"repository"` | ||||
| } | ||||
|  | ||||
| type PullRequest struct { | ||||
| 	Edges []PullRequestEdge `json:"edges"` | ||||
| } | ||||
|  | ||||
| type PullRequestEdge struct { | ||||
| 	Node PullRequestNode `json:"node"` | ||||
| } | ||||
|  | ||||
| type PullRequestNode struct { | ||||
| 	Title               string                `json:"title"` | ||||
| 	HeadRefName         string                `json:"headRefName"` | ||||
| 	Number              int                   `json:"number"` | ||||
| 	Url                 string                `json:"url"` | ||||
| 	HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"` | ||||
| 	State               string                `json:"state"` | ||||
| } | ||||
|  | ||||
| type GithubRepositoryOwner struct { | ||||
| 	Login string `json:"login"` | ||||
| } | ||||
|  | ||||
| func fetchPullRequestsQuery(branches []string, owner string, repo string) string { | ||||
| 	var queries []string | ||||
| 	for i, branch := range branches { | ||||
| 		// We're making a sub-query per branch, and arbitrarily labelling each subquery | ||||
| 		// as a1, a2, etc. | ||||
| 		fieldName := fmt.Sprintf("a%d", i+1) | ||||
| 		// TODO: scope down by remote too if we can (right now if you search for master, you can get multiple results back, and all from forks) | ||||
| 		queries = append(queries, fmt.Sprintf(`%s: pullRequests(first: 1, headRefName: "%s") { | ||||
|       edges { | ||||
|         node { | ||||
|           title | ||||
|           headRefName | ||||
|           state | ||||
|           number | ||||
|           url | ||||
|           headRepositoryOwner { | ||||
|             login | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }`, fieldName, branch)) | ||||
| 	} | ||||
|  | ||||
| 	queryString := fmt.Sprintf(`{ | ||||
|   repository(owner: "%s", name: "%s") { | ||||
|     %s | ||||
|   } | ||||
| }`, owner, repo, strings.Join(queries, "\n")) | ||||
|  | ||||
| 	return queryString | ||||
| } | ||||
|  | ||||
| // FetchRecentPRs fetches recent pull requests using GraphQL. | ||||
| func (self *GitHubCommands) FetchRecentPRs(branches []string) ([]*models.GithubPullRequest, error) { | ||||
| 	repoOwner, repoName, err := self.GetBaseRepoOwnerAndName() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	t := time.Now() | ||||
|  | ||||
| 	var g errgroup.Group | ||||
| 	results := make(chan []*models.GithubPullRequest) | ||||
|  | ||||
| 	// We want at most 5 concurrent requests, but no less than 10 branches per request | ||||
| 	concurrency := 5 | ||||
| 	minBranchesPerRequest := 10 | ||||
| 	branchesPerRequest := max(len(branches)/concurrency, minBranchesPerRequest) | ||||
| 	for i := 0; i < len(branches); i += branchesPerRequest { | ||||
| 		end := i + branchesPerRequest | ||||
| 		if end > len(branches) { | ||||
| 			end = len(branches) | ||||
| 		} | ||||
| 		branchChunk := branches[i:end] | ||||
|  | ||||
| 		// Launch a goroutine for each chunk of branches | ||||
| 		g.Go(func() error { | ||||
| 			prs, err := self.FetchRecentPRsAux(repoOwner, repoName, branchChunk) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			results <- prs | ||||
| 			return nil | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	// Close the results channel when all goroutines are done | ||||
| 	go func() { | ||||
| 		g.Wait() | ||||
| 		close(results) | ||||
| 	}() | ||||
|  | ||||
| 	// Collect results from all goroutines | ||||
| 	var allPRs []*models.GithubPullRequest | ||||
| 	for prs := range results { | ||||
| 		allPRs = append(allPRs, prs...) | ||||
| 	} | ||||
|  | ||||
| 	if err := g.Wait(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	self.Log.Warnf("Fetched PRs in %s", time.Since(t)) | ||||
|  | ||||
| 	return allPRs, nil | ||||
| } | ||||
|  | ||||
| func (self *GitHubCommands) FetchRecentPRsAux(repoOwner string, repoName string, branches []string) ([]*models.GithubPullRequest, error) { | ||||
| 	queryString := fetchPullRequestsQuery(branches, repoOwner, repoName) | ||||
| 	escapedQueryString := strconv.Quote(queryString) | ||||
|  | ||||
| 	body := fmt.Sprintf(`{"query": %s}`, escapedQueryString) | ||||
| 	req, err := http.NewRequest("POST", "https://api.github.com/graphql", bytes.NewBuffer([]byte(body))) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	defaultHost, _ := auth.DefaultHost() | ||||
| 	token, _ := auth.TokenForHost(defaultHost) | ||||
| 	if token == "" { | ||||
| 		return nil, fmt.Errorf("No token found for GitHub") | ||||
| 	} | ||||
| 	req.Header.Set("Authorization", "token "+token) | ||||
| 	req.Header.Set("Content-Type", "application/json") | ||||
|  | ||||
| 	client := &http.Client{} | ||||
| 	resp, err := client.Do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
|  | ||||
| 	if resp.StatusCode != http.StatusOK { | ||||
| 		bodyStr := new(bytes.Buffer) | ||||
| 		bodyStr.ReadFrom(resp.Body) | ||||
| 		return nil, fmt.Errorf("GraphQL query failed with status: %s. Body: %s", resp.Status, bodyStr.String()) | ||||
| 	} | ||||
|  | ||||
| 	bodyBytes, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	var result Response | ||||
| 	err = json.Unmarshal(bodyBytes, &result) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	prs := []*models.GithubPullRequest{} | ||||
| 	for _, repoQuery := range result.Data.Repository { | ||||
| 		for _, edge := range repoQuery.Edges { | ||||
| 			node := edge.Node | ||||
| 			pr := &models.GithubPullRequest{ | ||||
| 				HeadRefName: node.HeadRefName, | ||||
| 				Number:      node.Number, | ||||
| 				State:       node.State, | ||||
| 				Url:         node.Url, | ||||
| 				HeadRepositoryOwner: models.GithubRepositoryOwner{ | ||||
| 					Login: node.HeadRepositoryOwner.Login, | ||||
| 				}, | ||||
| 			} | ||||
| 			prs = append(prs, pr) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return prs, nil | ||||
| } | ||||
|  | ||||
| // returns a map from branch name to pull request | ||||
| func GenerateGithubPullRequestMap( | ||||
| 	prs []*models.GithubPullRequest, | ||||
| 	branches []*models.Branch, | ||||
| 	remotes []*models.Remote, | ||||
| ) map[string]*models.GithubPullRequest { | ||||
| 	res := map[string]*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.Name] = &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, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // return <installed>, <valid version> | ||||
| func (self *GitHubCommands) DetermineGitHubCliState() (bool, bool) { | ||||
| 	output, err := self.cmd.New([]string{"gh", "--version"}).DontLog().RunWithOutput() | ||||
| 	if err != nil { | ||||
| 		// assuming a failure here means that it's not installed | ||||
| 		return false, false | ||||
| 	} | ||||
|  | ||||
| 	if !isGhVersionValid(output) { | ||||
| 		return true, false | ||||
| 	} | ||||
|  | ||||
| 	return true, true | ||||
| } | ||||
|  | ||||
| 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 (self *GitHubCommands) InGithubRepo() bool { | ||||
| 	remotes, err := self.repo.Remotes() | ||||
| 	if err != nil { | ||||
| 		self.Log.Error(err) | ||||
| 		return false | ||||
| 	} | ||||
|  | ||||
| 	if len(remotes) == 0 { | ||||
| 		return false | ||||
| 	} | ||||
|  | ||||
| 	remote := GetMainRemote(remotes) | ||||
|  | ||||
| 	if len(remote.Config().URLs) == 0 { | ||||
| 		return false | ||||
| 	} | ||||
|  | ||||
| 	url := remote.Config().URLs[0] | ||||
| 	return strings.Contains(url, "github.com") | ||||
| } | ||||
|  | ||||
| func GetMainRemote(remotes []*gogit.Remote) *gogit.Remote { | ||||
| 	for _, remote := range remotes { | ||||
| 		if remote.Config().Name == "origin" { | ||||
| 			return remote | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// need to sort remotes by name so that this is deterministic | ||||
| 	return lo.MinBy(remotes, func(a, b *gogit.Remote) bool { | ||||
| 		return a.Config().Name < b.Config().Name | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func GetSuggestedRemoteName(remotes []*models.Remote) string { | ||||
| 	if len(remotes) == 0 { | ||||
| 		return "origin" | ||||
| 	} | ||||
|  | ||||
| 	for _, remote := range remotes { | ||||
| 		if remote.Name == "origin" { | ||||
| 			return remote.Name | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return remotes[0].Name | ||||
| } | ||||
|  | ||||
| func (self *GitHubCommands) GetBaseRepoOwnerAndName() (string, string, error) { | ||||
| 	remotes, err := self.repo.Remotes() | ||||
| 	if err != nil { | ||||
| 		return "", "", err | ||||
| 	} | ||||
|  | ||||
| 	if len(remotes) == 0 { | ||||
| 		return "", "", fmt.Errorf("No remotes found") | ||||
| 	} | ||||
|  | ||||
| 	firstRemote := remotes[0] | ||||
| 	if len(firstRemote.Config().URLs) == 0 { | ||||
| 		return "", "", fmt.Errorf("No URLs found for remote") | ||||
| 	} | ||||
|  | ||||
| 	url := firstRemote.Config().URLs[0] | ||||
|  | ||||
| 	repoInfo := getRepoInfoFromURL(url) | ||||
|  | ||||
| 	return repoInfo.Owner, repoInfo.Repository, nil | ||||
| } | ||||
							
								
								
									
										71
									
								
								pkg/commands/git_commands/github_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								pkg/commands/git_commands/github_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| package git_commands | ||||
|  | ||||
| import ( | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/jesseduffield/lazygit/pkg/commands/models" | ||||
| 	"github.com/samber/lo" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
|  | ||||
| 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) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestGetSuggestedRemoteName(t *testing.T) { | ||||
| 	cases := []struct { | ||||
| 		remotes  []*models.Remote | ||||
| 		expected string | ||||
| 	}{ | ||||
| 		{mkRemoteList(), "origin"}, | ||||
| 		{mkRemoteList("upstream", "origin", "foo"), "origin"}, | ||||
| 		{mkRemoteList("upstream", "foo", "bar"), "upstream"}, | ||||
| 	} | ||||
|  | ||||
| 	for _, c := range cases { | ||||
| 		result := GetSuggestedRemoteName(c.remotes) | ||||
| 		assert.EqualValues(t, c.expected, result) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func mkRemoteList(names ...string) []*models.Remote { | ||||
| 	return lo.Map(names, func(name string, _ int) *models.Remote { | ||||
| 		return &models.Remote{Name: name} | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										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)?$`, | ||||
| 	`^.*?@.*:(?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/{{.CommitHash}}", | ||||
| 	regexStrings:                    defaultUrlRegexStrings, | ||||
| 	repoURLTemplate:                 defaultRepoURLTemplate, | ||||
| 	repoNameTemplate:                defaultRepoNameTemplate, | ||||
| } | ||||
|  | ||||
| var bitbucketServiceDef = ServiceDefinition{ | ||||
| @@ -29,6 +34,7 @@ var bitbucketServiceDef = ServiceDefinition{ | ||||
| 		`^.*@.*:(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`, | ||||
| 	}, | ||||
| 	repoURLTemplate: defaultRepoURLTemplate, | ||||
| 	repoNameTemplate: defaultRepoNameTemplate, | ||||
| } | ||||
|  | ||||
| var gitLabServiceDef = ServiceDefinition{ | ||||
| @@ -38,6 +44,7 @@ var gitLabServiceDef = ServiceDefinition{ | ||||
| 	commitURL:                       "/-/commit/{{.CommitHash}}", | ||||
| 	regexStrings:                    defaultUrlRegexStrings, | ||||
| 	repoURLTemplate:                 defaultRepoURLTemplate, | ||||
| 	repoNameTemplate:                defaultRepoNameTemplate, | ||||
| } | ||||
|  | ||||
| var azdoServiceDef = ServiceDefinition{ | ||||
| @@ -52,6 +59,8 @@ var azdoServiceDef = ServiceDefinition{ | ||||
| 		`^https://.*/(?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{ | ||||
| @@ -64,6 +73,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 giteaServiceDef = ServiceDefinition{ | ||||
|   | ||||
| @@ -61,6 +61,18 @@ func (self *HostingServiceMgr) GetCommitURL(commitHash 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 | ||||
| } | ||||
| @@ -145,23 +163,44 @@ type ServiceDefinition struct { | ||||
|  | ||||
| 	// can expect 'webdomain' to be passed in. Otherwise, you get to pick what we match in the regex | ||||
| 	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 | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										24
									
								
								pkg/commands/models/github.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								pkg/commands/models/github.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| package models | ||||
|  | ||||
| // TODO: see if I need to store the head repo name in case it differs from the base repo | ||||
| 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 { | ||||
| 	Login string `json:"login"` | ||||
| } | ||||
| @@ -313,6 +313,8 @@ type GitConfig struct { | ||||
| 	RemoteBranchSortOrder string `yaml:"remoteBranchSortOrder" jsonschema:"enum=date,enum=alphabetical"` | ||||
| 	// When copying commit hashes to the clipboard, truncate them to this length. Set to 40 to disable truncation. | ||||
| 	TruncateCopiedCommitHashesTo int `yaml:"truncateCopiedCommitHashesTo"` | ||||
| 	// If true and if if `gh` is installed and on version >=2, we will use `gh` to display pull requests against branches. | ||||
| 	EnableGithubCli bool `yaml:"enableGithubCli"` | ||||
| } | ||||
|  | ||||
| type PagerType string | ||||
| @@ -845,6 +847,7 @@ func GetDefaultConfig() *UserConfig { | ||||
| 			BranchPrefix:                 "", | ||||
| 			ParseEmoji:                   false, | ||||
| 			TruncateCopiedCommitHashesTo: 12, | ||||
| 			EnableGithubCli:              true, | ||||
| 		}, | ||||
| 		Refresher: RefresherConfig{ | ||||
| 			RefreshInterval: 10, | ||||
|   | ||||
| @@ -126,7 +126,7 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru | ||||
| func (self *BackgroundRoutineMgr) backgroundFetch() (err error) { | ||||
| 	err = self.gui.git.Sync.FetchBackground() | ||||
|  | ||||
| 	self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.SYNC}) | ||||
| 	self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS}, Mode: types.SYNC}) | ||||
|  | ||||
| 	if err == nil { | ||||
| 		err = self.gui.helpers.BranchesHelper.AutoForwardBranches() | ||||
|   | ||||
| @@ -28,6 +28,8 @@ func NewBranchesContext(c *ContextCommon) *BranchesContext { | ||||
| 		return presentation.GetBranchListDisplayStrings( | ||||
| 			viewModel.GetItems(), | ||||
| 			c.State().GetItemOperation, | ||||
| 			c.Model().PullRequests, | ||||
| 			c.Model().Remotes, | ||||
| 			c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL, | ||||
| 			c.Modes().Diffing.Ref, | ||||
| 			c.Views().Branches.InnerWidth()+c.Views().Branches.OriginX(), | ||||
|   | ||||
| @@ -68,6 +68,7 @@ func (gui *Gui) resetHelpersAndControllers() { | ||||
| 		mergeConflictsHelper, | ||||
| 		worktreeHelper, | ||||
| 		searchHelper, | ||||
| 		suggestionsHelper, | ||||
| 	) | ||||
| 	diffHelper := helpers.NewDiffHelper(helperCommon) | ||||
| 	cherryPickHelper := helpers.NewCherryPickHelper( | ||||
|   | ||||
| @@ -27,7 +27,6 @@ type Helpers struct { | ||||
| 	MergeAndRebase *MergeAndRebaseHelper | ||||
| 	MergeConflicts *MergeConflictsHelper | ||||
| 	CherryPick     *CherryPickHelper | ||||
| 	Host           *HostHelper | ||||
| 	PatchBuilding  *PatchBuildingHelper | ||||
| 	Staging        *StagingHelper | ||||
| 	GPG            *GpgHelper | ||||
| @@ -53,6 +52,7 @@ type Helpers struct { | ||||
| 	Search            *SearchHelper | ||||
| 	Worktree          *WorktreeHelper | ||||
| 	SubCommits        *SubCommitsHelper | ||||
| 	Host              *HostHelper | ||||
| } | ||||
|  | ||||
| func NewStubHelpers() *Helpers { | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package helpers | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| @@ -27,6 +28,7 @@ type RefreshHelper struct { | ||||
| 	mergeConflictsHelper *MergeConflictsHelper | ||||
| 	worktreeHelper       *WorktreeHelper | ||||
| 	searchHelper         *SearchHelper | ||||
| 	suggestionsHelper    *SuggestionsHelper | ||||
| } | ||||
|  | ||||
| func NewRefreshHelper( | ||||
| @@ -38,6 +40,7 @@ func NewRefreshHelper( | ||||
| 	mergeConflictsHelper *MergeConflictsHelper, | ||||
| 	worktreeHelper *WorktreeHelper, | ||||
| 	searchHelper *SearchHelper, | ||||
| 	suggestionsHelper *SuggestionsHelper, | ||||
| ) *RefreshHelper { | ||||
| 	return &RefreshHelper{ | ||||
| 		c:                    c, | ||||
| @@ -48,6 +51,7 @@ func NewRefreshHelper( | ||||
| 		mergeConflictsHelper: mergeConflictsHelper, | ||||
| 		worktreeHelper:       worktreeHelper, | ||||
| 		searchHelper:         searchHelper, | ||||
| 		suggestionsHelper:    suggestionsHelper, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -91,6 +95,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { | ||||
| 				types.STATUS, | ||||
| 				types.BISECT_INFO, | ||||
| 				types.STAGING, | ||||
| 				types.PULL_REQUESTS, | ||||
| 			}) | ||||
| 		} else { | ||||
| 			scopeSet = set.NewFromSlice(options.Scope) | ||||
| @@ -117,6 +122,10 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if scopeSet.Includes(types.PULL_REQUESTS) { | ||||
| 			refresh("pull requests", func() { _ = self.refreshGithubPullRequests() }) | ||||
| 		} | ||||
|  | ||||
| 		includeWorktreesWithBranches := false | ||||
| 		if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) || scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { | ||||
| 			// whenever we change commits, we should update branches because the upstream/downstream | ||||
| @@ -757,3 +766,130 @@ func (self *RefreshHelper) refreshView(context types.Context) { | ||||
| 		return nil | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func (self *RefreshHelper) refreshGithubPullRequests() error { | ||||
| 	self.c.Mutexes().RefreshingPullRequestsMutex.Lock() | ||||
| 	defer self.c.Mutexes().RefreshingPullRequestsMutex.Unlock() | ||||
|  | ||||
| 	if !self.c.UserConfig().Git.EnableGithubCli { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if !self.c.Git().GitHub.InGithubRepo() { | ||||
| 		self.c.Model().PullRequests = []*models.GithubPullRequest{} | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	switch self.c.State().GetGitHubCliState() { | ||||
| 	case types.UNKNOWN: | ||||
| 		state := self.determineGithubCliState() | ||||
| 		self.c.State().SetGitHubCliState(state) | ||||
| 		if state != types.VALID { | ||||
| 			if state == types.INVALID_VERSION { | ||||
| 				// todo: i18n | ||||
| 				self.c.LogAction("gh version is too old (must be version 2 or greater), so pull requests will not be shown against branches.") | ||||
| 			} | ||||
| 			return nil | ||||
| 		} | ||||
| 	case types.VALID: | ||||
| 		// continue on | ||||
| 	default: | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if err := self.c.Git().GitHub.BaseRepo(); err != nil { | ||||
| 		ok, err := self.promptForBaseGithubRepo() | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		if !ok { | ||||
| 			return nil | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if err := self.setGithubPullRequests(); err != nil { | ||||
| 		self.c.LogAction(fmt.Sprintf("Error fetching pull requests from GitHub: %s", err.Error())) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (self *RefreshHelper) promptForBaseGithubRepo() (bool, error) { | ||||
| 	err := self.refreshRemotes() | ||||
| 	if err != nil { | ||||
| 		return false, err | ||||
| 	} | ||||
|  | ||||
| 	switch len(self.c.Model().Remotes) { | ||||
| 	case 0: | ||||
| 		return false, nil | ||||
| 	case 1: | ||||
| 		remote := self.c.Model().Remotes[0] | ||||
|  | ||||
| 		if len(remote.Urls) == 0 { | ||||
| 			return false, nil | ||||
| 		} | ||||
|  | ||||
| 		repoName, err := self.c.Git().HostingService.GetRepoNameFromRemoteURL(remote.Urls[0]) | ||||
| 		if err != nil { | ||||
| 			self.c.Log.Error(err) | ||||
| 			return false, nil | ||||
| 		} | ||||
|  | ||||
| 		_, err = self.c.Git().GitHub.SetBaseRepo(repoName) | ||||
| 		if err != nil { | ||||
| 			self.c.Log.Error(err) | ||||
| 		} | ||||
|  | ||||
| 		return true, nil | ||||
| 	default: | ||||
| 		self.c.Prompt(types.PromptOpts{ | ||||
| 			Title:               self.c.Tr.SelectRemoteRepository, | ||||
| 			InitialContent:      "", | ||||
| 			FindSuggestionsFunc: self.suggestionsHelper.GetRemoteRepoSuggestionsFunc(), | ||||
| 			HandleConfirm: func(repository string) error { | ||||
| 				return self.c.WithWaitingStatus(self.c.Tr.LcSelectingRemote, func(gocui.Task) error { | ||||
| 					// `repository` is something like 'jesseduffield/lazygit' | ||||
| 					_, err := self.c.Git().GitHub.SetBaseRepo(repository) | ||||
| 					if err != nil { | ||||
| 						return err | ||||
| 					} | ||||
|  | ||||
| 					return self.refreshGithubPullRequests() | ||||
| 				}) | ||||
| 			}, | ||||
| 		}) | ||||
|  | ||||
| 		return false, nil | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (self *RefreshHelper) determineGithubCliState() types.GitHubCliState { | ||||
| 	installed, validVersion := self.c.Git().GitHub.DetermineGitHubCliState() | ||||
| 	if validVersion { | ||||
| 		return types.VALID | ||||
| 	} else if installed { | ||||
| 		return types.INVALID_VERSION | ||||
| 	} | ||||
| 	return types.NOT_INSTALLED | ||||
| } | ||||
|  | ||||
| func (self *RefreshHelper) setGithubPullRequests() error { | ||||
| 	branches := lo.Filter(self.c.Model().Branches, func(branch *models.Branch, _ int) bool { | ||||
| 		return branch.IsTrackingRemote() | ||||
| 	}) | ||||
| 	branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { | ||||
| 		return branch.UpstreamBranch | ||||
| 	}) | ||||
|  | ||||
| 	prs, err := self.c.Git().GitHub.FetchRecentPRs(branchNames) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	self.c.Model().PullRequests = prs | ||||
|  | ||||
| 	self.c.PostRefreshUpdate(self.c.Contexts().Branches) | ||||
| 	return nil | ||||
| } | ||||
|   | ||||
| @@ -65,6 +65,30 @@ func (self *SuggestionsHelper) getBranchNames() []string { | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func (self *SuggestionsHelper) GetRemoteRepoSuggestionsFunc() func(string) []*types.Suggestion { | ||||
| 	repoNames := self.getRemoteRepoNames() | ||||
|  | ||||
| 	return FilterFunc(repoNames, self.c.UserConfig().Gui.UseFuzzySearch()) | ||||
| } | ||||
|  | ||||
| func (self *SuggestionsHelper) getRemoteRepoNames() []string { | ||||
| 	remotes := self.c.Model().Remotes | ||||
| 	result := make([]string, 0, len(remotes)) | ||||
| 	for _, remote := range remotes { | ||||
| 		if len(remote.Urls) == 0 { | ||||
| 			continue | ||||
| 		} | ||||
| 		repoName, err := self.c.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() | ||||
|  | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import ( | ||||
| 	"errors" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/jesseduffield/lazygit/pkg/commands/git_commands" | ||||
| 	"github.com/jesseduffield/lazygit/pkg/commands/models" | ||||
| 	"github.com/jesseduffield/lazygit/pkg/gui/types" | ||||
| ) | ||||
| @@ -60,19 +61,5 @@ func (self *UpstreamHelper) PromptForUpstreamWithoutInitialContent(_ *models.Bra | ||||
| } | ||||
|  | ||||
| func (self *UpstreamHelper) GetSuggestedRemote() string { | ||||
| 	return getSuggestedRemote(self.c.Model().Remotes) | ||||
| } | ||||
|  | ||||
| func getSuggestedRemote(remotes []*models.Remote) string { | ||||
| 	if len(remotes) == 0 { | ||||
| 		return "origin" | ||||
| 	} | ||||
|  | ||||
| 	for _, remote := range remotes { | ||||
| 		if remote.Name == "origin" { | ||||
| 			return remote.Name | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return remotes[0].Name | ||||
| 	return git_commands.GetSuggestedRemoteName(self.c.Model().Remotes) | ||||
| } | ||||
|   | ||||
| @@ -1,31 +0,0 @@ | ||||
| package helpers | ||||
|  | ||||
| import ( | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/jesseduffield/lazygit/pkg/commands/models" | ||||
| 	"github.com/samber/lo" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
|  | ||||
| func TestGetSuggestedRemote(t *testing.T) { | ||||
| 	cases := []struct { | ||||
| 		remotes  []*models.Remote | ||||
| 		expected string | ||||
| 	}{ | ||||
| 		{mkRemoteList(), "origin"}, | ||||
| 		{mkRemoteList("upstream", "origin", "foo"), "origin"}, | ||||
| 		{mkRemoteList("upstream", "foo", "bar"), "upstream"}, | ||||
| 	} | ||||
|  | ||||
| 	for _, c := range cases { | ||||
| 		result := getSuggestedRemote(c.remotes) | ||||
| 		assert.EqualValues(t, c.expected, result) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func mkRemoteList(names ...string) []*models.Remote { | ||||
| 	return lo.Map(names, func(name string, _ int) *models.Remote { | ||||
| 		return &models.Remote{Name: name} | ||||
| 	}) | ||||
| } | ||||
| @@ -147,6 +147,7 @@ type Gui struct { | ||||
| 	integrationTest integrationTypes.IntegrationTest | ||||
|  | ||||
| 	afterLayoutFuncs chan func() error | ||||
| 	gitHubCliState   types.GitHubCliState | ||||
| } | ||||
|  | ||||
| type StateAccessor struct { | ||||
| @@ -220,6 +221,14 @@ func (self *StateAccessor) ClearItemOperation(item types.HasUrn) { | ||||
| 	delete(self.gui.itemOperations, item.URN()) | ||||
| } | ||||
|  | ||||
| func (self *StateAccessor) GetGitHubCliState() types.GitHubCliState { | ||||
| 	return self.gui.gitHubCliState | ||||
| } | ||||
|  | ||||
| func (self *StateAccessor) SetGitHubCliState(value types.GitHubCliState) { | ||||
| 	self.gui.gitHubCliState = value | ||||
| } | ||||
|  | ||||
| // we keep track of some stuff from one render to the next to see if certain | ||||
| // things have changed | ||||
| type PrevLayout struct { | ||||
| @@ -569,6 +578,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { | ||||
| 			Authors:               map[string]*models.Author{}, | ||||
| 			MainBranches:          git_commands.NewMainBranches(gui.c.Common, gui.os.Cmd), | ||||
| 			HashPool:              &utils.StringPool{}, | ||||
| 			PullRequests:          make([]*models.GithubPullRequest, 0), | ||||
| 		}, | ||||
| 		Modes: &types.Modes{ | ||||
| 			Filtering:        filtering.New(startArgs.FilterPath, ""), | ||||
|   | ||||
| @@ -29,6 +29,8 @@ var colorPatterns *colorMatcher | ||||
| func GetBranchListDisplayStrings( | ||||
| 	branches []*models.Branch, | ||||
| 	getItemOperation func(item types.HasUrn) types.ItemOperation, | ||||
| 	pullRequests []*models.GithubPullRequest, | ||||
| 	remotes []*models.Remote, | ||||
| 	fullDescription bool, | ||||
| 	diffName string, | ||||
| 	viewWidth int, | ||||
| @@ -36,9 +38,15 @@ func GetBranchListDisplayStrings( | ||||
| 	userConfig *config.UserConfig, | ||||
| 	worktrees []*models.Worktree, | ||||
| ) [][]string { | ||||
| 	prs := git_commands.GenerateGithubPullRequestMap( | ||||
| 		pullRequests, | ||||
| 		branches, | ||||
| 		remotes, | ||||
| 	) | ||||
|  | ||||
| 	return lo.Map(branches, func(branch *models.Branch, _ int) []string { | ||||
| 		diffed := branch.Name == diffName | ||||
| 		return getBranchDisplayStrings(branch, getItemOperation(branch), fullDescription, diffed, viewWidth, tr, userConfig, worktrees, time.Now()) | ||||
| 		return getBranchDisplayStrings(branch, getItemOperation(branch), fullDescription, diffed, viewWidth, tr, userConfig, worktrees, time.Now(), prs) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| @@ -53,6 +61,7 @@ func getBranchDisplayStrings( | ||||
| 	userConfig *config.UserConfig, | ||||
| 	worktrees []*models.Worktree, | ||||
| 	now time.Time, | ||||
| 	prs map[string]*models.GithubPullRequest, | ||||
| ) []string { | ||||
| 	checkedOutByWorkTree := git_commands.CheckedOutByOtherWorktree(b, worktrees) | ||||
| 	showCommitHash := fullDescription || userConfig.Gui.ShowBranchCommitHash | ||||
| @@ -101,6 +110,7 @@ func getBranchDisplayStrings( | ||||
| 	if checkedOutByWorkTree { | ||||
| 		coloredName = fmt.Sprintf("%s %s", coloredName, style.FgDefault.Sprint(worktreeIcon)) | ||||
| 	} | ||||
|  | ||||
| 	if len(branchStatus) > 0 { | ||||
| 		coloredName = fmt.Sprintf("%s %s", coloredName, branchStatus) | ||||
| 	} | ||||
| @@ -111,14 +121,22 @@ func getBranchDisplayStrings( | ||||
| 	} | ||||
|  | ||||
| 	res := make([]string, 0, 6) | ||||
|  | ||||
| 	res = append(res, recencyColor.Sprint(b.Recency)) | ||||
|  | ||||
| 	if icons.IsIconEnabled() { | ||||
| 		res = append(res, nameTextStyle.Sprint(icons.IconForBranch(b))) | ||||
| 	} | ||||
|  | ||||
| 	if showCommitHash { | ||||
| 		res = append(res, utils.ShortHash(b.CommitHash)) | ||||
| 	pr, hasPr := prs[b.Name] | ||||
| 	if hasPr { | ||||
| 		if icons.IsIconEnabled() { | ||||
| 			res = append(res, prColor(pr.State).Sprint(icons.IconForBranch(b))) | ||||
| 		} else { | ||||
| 			res = append(res, prColor(pr.State).Sprint("⬤")) | ||||
| 		} | ||||
| 	} else { | ||||
| 		if icons.IsIconEnabled() { | ||||
| 			res = append(res, style.FgDefault.Sprint(icons.IconForBranch(b))) | ||||
| 		} else { | ||||
| 			res = append(res, style.FgDefault.Sprint("⬤")) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if divergence != "" { | ||||
| @@ -128,8 +146,13 @@ func getBranchDisplayStrings( | ||||
| 			coloredName += style.FgCyan.Sprint(divergence) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	res = append(res, coloredName) | ||||
|  | ||||
| 	if showCommitHash { | ||||
| 		res = append(res, utils.ShortHash(b.CommitHash)) | ||||
| 	} | ||||
|  | ||||
| 	if fullDescription { | ||||
| 		res = append( | ||||
| 			res, | ||||
| @@ -229,3 +252,24 @@ func SetCustomBranches(customBranchColors map[string]string, isRegex bool) { | ||||
| 		isRegex:  isRegex, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // 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 | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -351,7 +351,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { | ||||
| 		} | ||||
|  | ||||
| 		t.Run(fmt.Sprintf("getBranchDisplayStrings_%d", i), func(t *testing.T) { | ||||
| 			strings := getBranchDisplayStrings(s.branch, s.itemOperation, s.fullDescription, false, s.viewWidth, c.Tr, c.UserConfig(), worktrees, time.Time{}) | ||||
| 			strings := getBranchDisplayStrings(s.branch, s.itemOperation, s.fullDescription, false, s.viewWidth, c.Tr, c.UserConfig(), worktrees, time.Time{}, map[string]*models.GithubPullRequest{}) | ||||
| 			assert.Equal(t, s.expected, strings) | ||||
| 		}) | ||||
| 	} | ||||
|   | ||||
| @@ -292,6 +292,7 @@ type Model struct { | ||||
| 	SubCommits   []*models.Commit | ||||
| 	Remotes      []*models.Remote | ||||
| 	Worktrees    []*models.Worktree | ||||
| 	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 | ||||
| @@ -322,15 +323,16 @@ type Model struct { | ||||
| } | ||||
|  | ||||
| type Mutexes struct { | ||||
| 	RefreshingFilesMutex    deadlock.Mutex | ||||
| 	RefreshingBranchesMutex deadlock.Mutex | ||||
| 	RefreshingStatusMutex   deadlock.Mutex | ||||
| 	LocalCommitsMutex       deadlock.Mutex | ||||
| 	SubCommitsMutex         deadlock.Mutex | ||||
| 	AuthorsMutex            deadlock.Mutex | ||||
| 	SubprocessMutex         deadlock.Mutex | ||||
| 	PopupMutex              deadlock.Mutex | ||||
| 	PtyMutex                deadlock.Mutex | ||||
| 	RefreshingFilesMutex        deadlock.Mutex | ||||
| 	RefreshingBranchesMutex     deadlock.Mutex | ||||
| 	RefreshingStatusMutex       deadlock.Mutex | ||||
| 	RefreshingPullRequestsMutex deadlock.Mutex | ||||
| 	LocalCommitsMutex           deadlock.Mutex | ||||
| 	SubCommitsMutex             deadlock.Mutex | ||||
| 	AuthorsMutex                deadlock.Mutex | ||||
| 	SubprocessMutex             deadlock.Mutex | ||||
| 	PopupMutex                  deadlock.Mutex | ||||
| 	PtyMutex                    deadlock.Mutex | ||||
| } | ||||
|  | ||||
| // A long-running operation associated with an item. For example, we'll show | ||||
| @@ -369,6 +371,8 @@ type IStateAccessor interface { | ||||
| 	GetItemOperation(item HasUrn) ItemOperation | ||||
| 	SetItemOperation(item HasUrn, operation ItemOperation) | ||||
| 	ClearItemOperation(item HasUrn) | ||||
| 	GetGitHubCliState() GitHubCliState | ||||
| 	SetGitHubCliState(GitHubCliState) | ||||
| } | ||||
|  | ||||
| type IRepoStateAccessor interface { | ||||
| @@ -405,3 +409,13 @@ const ( | ||||
| 	SCREEN_HALF | ||||
| 	SCREEN_FULL | ||||
| ) | ||||
|  | ||||
| // for keeping track of whether our github CLI is installed and on a valid version | ||||
| type GitHubCliState int | ||||
|  | ||||
| const ( | ||||
| 	UNKNOWN GitHubCliState = iota | ||||
| 	VALID | ||||
| 	NOT_INSTALLED | ||||
| 	INVALID_VERSION | ||||
| ) | ||||
|   | ||||
| @@ -22,6 +22,7 @@ const ( | ||||
| 	COMMIT_FILES | ||||
| 	// not actually a view. Will refactor this later | ||||
| 	BISECT_INFO | ||||
| 	PULL_REQUESTS | ||||
| ) | ||||
|  | ||||
| type RefreshMode int | ||||
|   | ||||
| @@ -593,6 +593,8 @@ type TranslationSet struct { | ||||
| 	CyclePagersDisabledReason             string | ||||
| 	StartSearch                           string | ||||
| 	StartFilter                           string | ||||
| 	SelectRemoteRepository                string | ||||
| 	LcSelectingRemote                     string | ||||
| 	Keybindings                           string | ||||
| 	KeybindingsLegend                     string | ||||
| 	KeybindingsMenuSectionLocal           string | ||||
| @@ -685,6 +687,8 @@ type TranslationSet struct { | ||||
| 	BackToParentRepo                      string | ||||
| 	Enter                                 string | ||||
| 	CopySubmoduleNameToClipboard          string | ||||
| 	MinGhVersionError                     string | ||||
| 	FailedToObtainGhVersionError          string | ||||
| 	RemoveSubmodule                       string | ||||
| 	RemoveSubmoduleTooltip                string | ||||
| 	RemoveSubmodulePrompt                 string | ||||
| @@ -1781,6 +1785,8 @@ func EnglishTranslationSet() *TranslationSet { | ||||
| 		EnterSubmoduleTooltip:                    "Enter submodule. After entering the submodule, you can press `{{.escape}}` to escape back to the parent repo.", | ||||
| 		BackToParentRepo:                         "Back to parent repo", | ||||
| 		CopySubmoduleNameToClipboard:             "Copy submodule name to clipboard", | ||||
| 		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.", | ||||
| 		FailedToObtainGhVersionError:             "Failed to obtain gh version. Output from running 'gh --version' was: %s", | ||||
| 		RemoveSubmodule:                          "Remove submodule", | ||||
| 		RemoveSubmodulePrompt:                    "Are you sure you want to remove submodule '%s' and its corresponding directory? This is irreversible.", | ||||
| 		RemoveSubmoduleTooltip:                   "Remove the selected submodule and its corresponding directory.", | ||||
|   | ||||
| @@ -442,6 +442,11 @@ | ||||
|           "type": "integer", | ||||
|           "description": "When copying commit hashes to the clipboard, truncate them to this length. Set to 40 to disable truncation.", | ||||
|           "default": 12 | ||||
|         }, | ||||
|         "enableGithubCli": { | ||||
|           "type": "boolean", | ||||
|           "description": "If true and if if `gh` is installed and on version \u003e=2, we will use `gh` to display pull requests against branches.", | ||||
|           "default": true | ||||
|         } | ||||
|       }, | ||||
|       "additionalProperties": false, | ||||
|   | ||||
| @@ -20,3 +20,4 @@ git: | ||||
|   # TODO: add tests which explicitly test auto-refresh functionality | ||||
|   autoRefresh: false | ||||
|   autoFetch: false | ||||
|   enableGithubCli: false | ||||
|   | ||||
							
								
								
									
										21
									
								
								vendor/github.com/cli/go-gh/v2/LICENSE
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								vendor/github.com/cli/go-gh/v2/LICENSE
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| MIT License | ||||
|  | ||||
| Copyright (c) 2021 GitHub Inc. | ||||
|  | ||||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
| of this software and associated documentation files (the "Software"), to deal | ||||
| in the Software without restriction, including without limitation the rights | ||||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||||
| copies of the Software, and to permit persons to whom the Software is | ||||
| furnished to do so, subject to the following conditions: | ||||
|  | ||||
| The above copyright notice and this permission notice shall be included in all | ||||
| copies or substantial portions of the Software. | ||||
|  | ||||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| SOFTWARE. | ||||
							
								
								
									
										70
									
								
								vendor/github.com/cli/go-gh/v2/internal/set/string_set.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								vendor/github.com/cli/go-gh/v2/internal/set/string_set.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| package set | ||||
|  | ||||
| var exists = struct{}{} | ||||
|  | ||||
| type stringSet struct { | ||||
| 	v []string | ||||
| 	m map[string]struct{} | ||||
| } | ||||
|  | ||||
| func NewStringSet() *stringSet { | ||||
| 	s := &stringSet{} | ||||
| 	s.m = make(map[string]struct{}) | ||||
| 	s.v = []string{} | ||||
| 	return s | ||||
| } | ||||
|  | ||||
| func (s *stringSet) Add(value string) { | ||||
| 	if s.Contains(value) { | ||||
| 		return | ||||
| 	} | ||||
| 	s.m[value] = exists | ||||
| 	s.v = append(s.v, value) | ||||
| } | ||||
|  | ||||
| func (s *stringSet) AddValues(values []string) { | ||||
| 	for _, v := range values { | ||||
| 		s.Add(v) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (s *stringSet) Remove(value string) { | ||||
| 	if !s.Contains(value) { | ||||
| 		return | ||||
| 	} | ||||
| 	delete(s.m, value) | ||||
| 	s.v = sliceWithout(s.v, value) | ||||
| } | ||||
|  | ||||
| func sliceWithout(s []string, v string) []string { | ||||
| 	idx := -1 | ||||
| 	for i, item := range s { | ||||
| 		if item == v { | ||||
| 			idx = i | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
| 	if idx < 0 { | ||||
| 		return s | ||||
| 	} | ||||
| 	return append(s[:idx], s[idx+1:]...) | ||||
| } | ||||
|  | ||||
| func (s *stringSet) RemoveValues(values []string) { | ||||
| 	for _, v := range values { | ||||
| 		s.Remove(v) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (s *stringSet) Contains(value string) bool { | ||||
| 	_, c := s.m[value] | ||||
| 	return c | ||||
| } | ||||
|  | ||||
| func (s *stringSet) Len() int { | ||||
| 	return len(s.m) | ||||
| } | ||||
|  | ||||
| func (s *stringSet) ToSlice() []string { | ||||
| 	return s.v | ||||
| } | ||||
							
								
								
									
										214
									
								
								vendor/github.com/cli/go-gh/v2/internal/yamlmap/yaml_map.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										214
									
								
								vendor/github.com/cli/go-gh/v2/internal/yamlmap/yaml_map.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,214 @@ | ||||
| // Package yamlmap is a wrapper of gopkg.in/yaml.v3 for interacting | ||||
| // with yaml data as if it were a map. | ||||
| package yamlmap | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
|  | ||||
| 	"gopkg.in/yaml.v3" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	modified = "modifed" | ||||
| ) | ||||
|  | ||||
| type Map struct { | ||||
| 	*yaml.Node | ||||
| } | ||||
|  | ||||
| var ErrNotFound = errors.New("not found") | ||||
| var ErrInvalidYaml = errors.New("invalid yaml") | ||||
| var ErrInvalidFormat = errors.New("invalid format") | ||||
|  | ||||
| func StringValue(value string) *Map { | ||||
| 	return &Map{&yaml.Node{ | ||||
| 		Kind:  yaml.ScalarNode, | ||||
| 		Tag:   "!!str", | ||||
| 		Value: value, | ||||
| 	}} | ||||
| } | ||||
|  | ||||
| func MapValue() *Map { | ||||
| 	return &Map{&yaml.Node{ | ||||
| 		Kind: yaml.MappingNode, | ||||
| 		Tag:  "!!map", | ||||
| 	}} | ||||
| } | ||||
|  | ||||
| func NullValue() *Map { | ||||
| 	return &Map{&yaml.Node{ | ||||
| 		Kind: yaml.ScalarNode, | ||||
| 		Tag:  "!!null", | ||||
| 	}} | ||||
| } | ||||
|  | ||||
| func Unmarshal(data []byte) (*Map, error) { | ||||
| 	var root yaml.Node | ||||
| 	err := yaml.Unmarshal(data, &root) | ||||
| 	if err != nil { | ||||
| 		return nil, ErrInvalidYaml | ||||
| 	} | ||||
| 	if len(root.Content) == 0 { | ||||
| 		return MapValue(), nil | ||||
| 	} | ||||
| 	if root.Content[0].Kind != yaml.MappingNode { | ||||
| 		return nil, ErrInvalidFormat | ||||
| 	} | ||||
| 	return &Map{root.Content[0]}, nil | ||||
| } | ||||
|  | ||||
| func Marshal(m *Map) ([]byte, error) { | ||||
| 	return yaml.Marshal(m.Node) | ||||
| } | ||||
|  | ||||
| func (m *Map) AddEntry(key string, value *Map) { | ||||
| 	keyNode := &yaml.Node{ | ||||
| 		Kind:  yaml.ScalarNode, | ||||
| 		Tag:   "!!str", | ||||
| 		Value: key, | ||||
| 	} | ||||
| 	m.Content = append(m.Content, keyNode, value.Node) | ||||
| 	m.SetModified() | ||||
| } | ||||
|  | ||||
| func (m *Map) Empty() bool { | ||||
| 	return m.Content == nil || len(m.Content) == 0 | ||||
| } | ||||
|  | ||||
| func (m *Map) FindEntry(key string) (*Map, error) { | ||||
| 	// Note: The content slice of a yamlMap looks like [key1, value1, key2, value2, ...]. | ||||
| 	// When iterating over the content slice we only want to compare the keys of the yamlMap. | ||||
| 	for i, v := range m.Content { | ||||
| 		if i%2 != 0 { | ||||
| 			continue | ||||
| 		} | ||||
| 		if v.Value == key { | ||||
| 			if i+1 < len(m.Content) { | ||||
| 				return &Map{m.Content[i+1]}, nil | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	return nil, ErrNotFound | ||||
| } | ||||
|  | ||||
| func (m *Map) Keys() []string { | ||||
| 	// Note: The content slice of a yamlMap looks like [key1, value1, key2, value2, ...]. | ||||
| 	// When iterating over the content slice we only want to select the keys of the yamlMap. | ||||
| 	keys := []string{} | ||||
| 	for i, v := range m.Content { | ||||
| 		if i%2 != 0 { | ||||
| 			continue | ||||
| 		} | ||||
| 		keys = append(keys, v.Value) | ||||
| 	} | ||||
| 	return keys | ||||
| } | ||||
|  | ||||
| func (m *Map) RemoveEntry(key string) error { | ||||
| 	// Note: The content slice of a yamlMap looks like [key1, value1, key2, value2, ...]. | ||||
| 	// When iterating over the content slice we only want to compare the keys of the yamlMap. | ||||
| 	// If we find they key to remove, remove the key and its value from the content slice. | ||||
| 	found, skipNext := false, false | ||||
| 	newContent := []*yaml.Node{} | ||||
| 	for i, v := range m.Content { | ||||
| 		if skipNext { | ||||
| 			skipNext = false | ||||
| 			continue | ||||
| 		} | ||||
| 		if i%2 != 0 || v.Value != key { | ||||
| 			newContent = append(newContent, v) | ||||
| 		} else { | ||||
| 			found = true | ||||
| 			skipNext = true | ||||
| 			m.SetModified() | ||||
| 		} | ||||
| 	} | ||||
| 	if !found { | ||||
| 		return ErrNotFound | ||||
| 	} | ||||
| 	m.Content = newContent | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (m *Map) SetEntry(key string, value *Map) { | ||||
| 	// Note: The content slice of a yamlMap looks like [key1, value1, key2, value2, ...]. | ||||
| 	// When iterating over the content slice we only want to compare the keys of the yamlMap. | ||||
| 	// If we find they key to set, set the next item in the content slice to the new value. | ||||
| 	m.SetModified() | ||||
| 	for i, v := range m.Content { | ||||
| 		if i%2 != 0 || v.Value != key { | ||||
| 			continue | ||||
| 		} | ||||
| 		if v.Value == key { | ||||
| 			if i+1 < len(m.Content) { | ||||
| 				m.Content[i+1] = value.Node | ||||
| 				return | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	m.AddEntry(key, value) | ||||
| } | ||||
|  | ||||
| // Note: This is a hack to introduce the concept of modified/unmodified | ||||
| // on top of gopkg.in/yaml.v3. This works by setting the Value property | ||||
| // of a MappingNode to a specific value and then later checking if the | ||||
| // node's Value property is that specific value. When a MappingNode gets | ||||
| // output as a string the Value property is not used, thus changing it | ||||
| // has no impact for our purposes. | ||||
| func (m *Map) SetModified() { | ||||
| 	// Can not mark a non-mapping node as modified | ||||
| 	if m.Node.Kind != yaml.MappingNode && m.Node.Tag == "!!null" { | ||||
| 		m.Node.Kind = yaml.MappingNode | ||||
| 		m.Node.Tag = "!!map" | ||||
| 	} | ||||
| 	if m.Node.Kind == yaml.MappingNode { | ||||
| 		m.Node.Value = modified | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Traverse map using BFS to set all nodes as unmodified. | ||||
| func (m *Map) SetUnmodified() { | ||||
| 	i := 0 | ||||
| 	queue := []*yaml.Node{m.Node} | ||||
| 	for { | ||||
| 		if i > (len(queue) - 1) { | ||||
| 			break | ||||
| 		} | ||||
| 		q := queue[i] | ||||
| 		i = i + 1 | ||||
| 		if q.Kind != yaml.MappingNode { | ||||
| 			continue | ||||
| 		} | ||||
| 		q.Value = "" | ||||
| 		queue = append(queue, q.Content...) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Traverse map using BFS to searach for any nodes that have been modified. | ||||
| func (m *Map) IsModified() bool { | ||||
| 	i := 0 | ||||
| 	queue := []*yaml.Node{m.Node} | ||||
| 	for { | ||||
| 		if i > (len(queue) - 1) { | ||||
| 			break | ||||
| 		} | ||||
| 		q := queue[i] | ||||
| 		i = i + 1 | ||||
| 		if q.Kind != yaml.MappingNode { | ||||
| 			continue | ||||
| 		} | ||||
| 		if q.Value == modified { | ||||
| 			return true | ||||
| 		} | ||||
| 		queue = append(queue, q.Content...) | ||||
| 	} | ||||
| 	return false | ||||
| } | ||||
|  | ||||
| func (m *Map) String() string { | ||||
| 	data, err := Marshal(m) | ||||
| 	if err != nil { | ||||
| 		return "" | ||||
| 	} | ||||
| 	return string(data) | ||||
| } | ||||
							
								
								
									
										194
									
								
								vendor/github.com/cli/go-gh/v2/pkg/auth/auth.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										194
									
								
								vendor/github.com/cli/go-gh/v2/pkg/auth/auth.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,194 @@ | ||||
| // Package auth is a set of functions for retrieving authentication tokens | ||||
| // and authenticated hosts. | ||||
| package auth | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"os/exec" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/cli/go-gh/v2/internal/set" | ||||
| 	"github.com/cli/go-gh/v2/pkg/config" | ||||
| 	"github.com/cli/safeexec" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	codespaces            = "CODESPACES" | ||||
| 	defaultSource         = "default" | ||||
| 	ghEnterpriseToken     = "GH_ENTERPRISE_TOKEN" | ||||
| 	ghHost                = "GH_HOST" | ||||
| 	ghToken               = "GH_TOKEN" | ||||
| 	github                = "github.com" | ||||
| 	githubEnterpriseToken = "GITHUB_ENTERPRISE_TOKEN" | ||||
| 	githubToken           = "GITHUB_TOKEN" | ||||
| 	hostsKey              = "hosts" | ||||
| 	localhost             = "github.localhost" | ||||
| 	oauthToken            = "oauth_token" | ||||
| ) | ||||
|  | ||||
| // TokenForHost retrieves an authentication token and the source of that token for the specified | ||||
| // host. The source can be either an environment variable, configuration file, or the system | ||||
| // keyring. In the latter case, this shells out to "gh auth token" to obtain the token. | ||||
| // | ||||
| // Returns "", "default" if no applicable token is found. | ||||
| func TokenForHost(host string) (string, string) { | ||||
| 	if token, source := TokenFromEnvOrConfig(host); token != "" { | ||||
| 		return token, source | ||||
| 	} | ||||
|  | ||||
| 	ghExe := os.Getenv("GH_PATH") | ||||
| 	if ghExe == "" { | ||||
| 		ghExe, _ = safeexec.LookPath("gh") | ||||
| 	} | ||||
|  | ||||
| 	if ghExe != "" { | ||||
| 		if token, source := tokenFromGh(ghExe, host); token != "" { | ||||
| 			return token, source | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return "", defaultSource | ||||
| } | ||||
|  | ||||
| // TokenFromEnvOrConfig retrieves an authentication token from environment variables or the config | ||||
| // file as fallback, but does not support reading the token from system keyring. Most consumers | ||||
| // should use TokenForHost. | ||||
| func TokenFromEnvOrConfig(host string) (string, string) { | ||||
| 	cfg, _ := config.Read(nil) | ||||
| 	return tokenForHost(cfg, host) | ||||
| } | ||||
|  | ||||
| func tokenForHost(cfg *config.Config, host string) (string, string) { | ||||
| 	host = normalizeHostname(host) | ||||
| 	if IsEnterprise(host) { | ||||
| 		if token := os.Getenv(ghEnterpriseToken); token != "" { | ||||
| 			return token, ghEnterpriseToken | ||||
| 		} | ||||
| 		if token := os.Getenv(githubEnterpriseToken); token != "" { | ||||
| 			return token, githubEnterpriseToken | ||||
| 		} | ||||
| 		if isCodespaces, _ := strconv.ParseBool(os.Getenv(codespaces)); isCodespaces { | ||||
| 			if token := os.Getenv(githubToken); token != "" { | ||||
| 				return token, githubToken | ||||
| 			} | ||||
| 		} | ||||
| 		if cfg != nil { | ||||
| 			token, _ := cfg.Get([]string{hostsKey, host, oauthToken}) | ||||
| 			return token, oauthToken | ||||
| 		} | ||||
| 	} | ||||
| 	if token := os.Getenv(ghToken); token != "" { | ||||
| 		return token, ghToken | ||||
| 	} | ||||
| 	if token := os.Getenv(githubToken); token != "" { | ||||
| 		return token, githubToken | ||||
| 	} | ||||
| 	if cfg != nil { | ||||
| 		token, _ := cfg.Get([]string{hostsKey, host, oauthToken}) | ||||
| 		return token, oauthToken | ||||
| 	} | ||||
| 	return "", defaultSource | ||||
| } | ||||
|  | ||||
| func tokenFromGh(path string, host string) (string, string) { | ||||
| 	cmd := exec.Command(path, "auth", "token", "--secure-storage", "--hostname", host) | ||||
| 	result, err := cmd.Output() | ||||
| 	if err != nil { | ||||
| 		return "", "gh" | ||||
| 	} | ||||
| 	return strings.TrimSpace(string(result)), "gh" | ||||
| } | ||||
|  | ||||
| // KnownHosts retrieves a list of hosts that have corresponding | ||||
| // authentication tokens, either from environment variables | ||||
| // or from the configuration file. | ||||
| // Returns an empty string slice if no hosts are found. | ||||
| func KnownHosts() []string { | ||||
| 	cfg, _ := config.Read(nil) | ||||
| 	return knownHosts(cfg) | ||||
| } | ||||
|  | ||||
| func knownHosts(cfg *config.Config) []string { | ||||
| 	hosts := set.NewStringSet() | ||||
| 	if host := os.Getenv(ghHost); host != "" { | ||||
| 		hosts.Add(host) | ||||
| 	} | ||||
| 	if token, _ := tokenForHost(cfg, github); token != "" { | ||||
| 		hosts.Add(github) | ||||
| 	} | ||||
| 	if cfg != nil { | ||||
| 		keys, err := cfg.Keys([]string{hostsKey}) | ||||
| 		if err == nil { | ||||
| 			hosts.AddValues(keys) | ||||
| 		} | ||||
| 	} | ||||
| 	return hosts.ToSlice() | ||||
| } | ||||
|  | ||||
| // DefaultHost retrieves an authenticated host and the source of host. | ||||
| // The source can be either an environment variable or from the | ||||
| // configuration file. | ||||
| // Returns "github.com", "default" if no viable host is found. | ||||
| func DefaultHost() (string, string) { | ||||
| 	cfg, _ := config.Read(nil) | ||||
| 	return defaultHost(cfg) | ||||
| } | ||||
|  | ||||
| func defaultHost(cfg *config.Config) (string, string) { | ||||
| 	if host := os.Getenv(ghHost); host != "" { | ||||
| 		return host, ghHost | ||||
| 	} | ||||
| 	if cfg != nil { | ||||
| 		keys, err := cfg.Keys([]string{hostsKey}) | ||||
| 		if err == nil && len(keys) == 1 { | ||||
| 			return keys[0], hostsKey | ||||
| 		} | ||||
| 	} | ||||
| 	return github, defaultSource | ||||
| } | ||||
|  | ||||
| // TenancyHost is the domain name of a tenancy GitHub instance. | ||||
| const tenancyHost = "ghe.com" | ||||
|  | ||||
| // IsEnterprise determines if a provided host is a GitHub Enterprise Server instance, | ||||
| // rather than GitHub.com or a tenancy GitHub instance. | ||||
| func IsEnterprise(host string) bool { | ||||
| 	normalizedHost := normalizeHostname(host) | ||||
| 	return normalizedHost != github && normalizedHost != localhost && !IsTenancy(normalizedHost) | ||||
| } | ||||
|  | ||||
| // IsTenancy determines if a provided host is a tenancy GitHub instance, | ||||
| // rather than GitHub.com or a GitHub Enterprise Server instance. | ||||
| func IsTenancy(host string) bool { | ||||
| 	normalizedHost := normalizeHostname(host) | ||||
| 	return strings.HasSuffix(normalizedHost, "."+tenancyHost) | ||||
| } | ||||
|  | ||||
| func normalizeHostname(host string) string { | ||||
| 	hostname := strings.ToLower(host) | ||||
| 	if strings.HasSuffix(hostname, "."+github) { | ||||
| 		return github | ||||
| 	} | ||||
| 	if strings.HasSuffix(hostname, "."+localhost) { | ||||
| 		return localhost | ||||
| 	} | ||||
| 	// This has been copied over from the cli/cli NormalizeHostname function | ||||
| 	// to ensure compatible behaviour but we don't fully understand when or | ||||
| 	// why it would be useful here. We can't see what harm will come of | ||||
| 	// duplicating the logic. | ||||
| 	if before, found := cutSuffix(hostname, "."+tenancyHost); found { | ||||
| 		idx := strings.LastIndex(before, ".") | ||||
| 		return fmt.Sprintf("%s.%s", before[idx+1:], tenancyHost) | ||||
| 	} | ||||
| 	return hostname | ||||
| } | ||||
|  | ||||
| // Backport strings.CutSuffix from Go 1.20. | ||||
| func cutSuffix(s, suffix string) (string, bool) { | ||||
| 	if !strings.HasSuffix(s, suffix) { | ||||
| 		return s, false | ||||
| 	} | ||||
| 	return s[:len(s)-len(suffix)], true | ||||
| } | ||||
							
								
								
									
										336
									
								
								vendor/github.com/cli/go-gh/v2/pkg/config/config.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										336
									
								
								vendor/github.com/cli/go-gh/v2/pkg/config/config.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,336 @@ | ||||
| // Package config is a set of types for interacting with the gh configuration files. | ||||
| // Note: This package is intended for use only in gh, any other use cases are subject | ||||
| // to breakage and non-backwards compatible updates. | ||||
| package config | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"io" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"runtime" | ||||
| 	"sync" | ||||
|  | ||||
| 	"github.com/cli/go-gh/v2/internal/yamlmap" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	appData       = "AppData" | ||||
| 	ghConfigDir   = "GH_CONFIG_DIR" | ||||
| 	localAppData  = "LocalAppData" | ||||
| 	xdgConfigHome = "XDG_CONFIG_HOME" | ||||
| 	xdgDataHome   = "XDG_DATA_HOME" | ||||
| 	xdgStateHome  = "XDG_STATE_HOME" | ||||
| 	xdgCacheHome  = "XDG_CACHE_HOME" | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	cfg     *Config | ||||
| 	once    sync.Once | ||||
| 	loadErr error | ||||
| ) | ||||
|  | ||||
| // Config is a in memory representation of the gh configuration files. | ||||
| // It can be thought of as map where entries consist of a key that | ||||
| // correspond to either a string value or a map value, allowing for | ||||
| // multi-level maps. | ||||
| type Config struct { | ||||
| 	entries *yamlmap.Map | ||||
| 	mu      sync.RWMutex | ||||
| } | ||||
|  | ||||
| // Get a string value from a Config. | ||||
| // The keys argument is a sequence of key values so that nested | ||||
| // entries can be retrieved. A undefined string will be returned | ||||
| // if trying to retrieve a key that corresponds to a map value. | ||||
| // Returns "", KeyNotFoundError if any of the keys can not be found. | ||||
| func (c *Config) Get(keys []string) (string, error) { | ||||
| 	c.mu.RLock() | ||||
| 	defer c.mu.RUnlock() | ||||
| 	m := c.entries | ||||
| 	for _, key := range keys { | ||||
| 		var err error | ||||
| 		m, err = m.FindEntry(key) | ||||
| 		if err != nil { | ||||
| 			return "", &KeyNotFoundError{key} | ||||
| 		} | ||||
| 	} | ||||
| 	return m.Value, nil | ||||
| } | ||||
|  | ||||
| // Keys enumerates a Config's keys. | ||||
| // The keys argument is a sequence of key values so that nested | ||||
| // map values can be have their keys enumerated. | ||||
| // Returns nil, KeyNotFoundError if any of the keys can not be found. | ||||
| func (c *Config) Keys(keys []string) ([]string, error) { | ||||
| 	c.mu.RLock() | ||||
| 	defer c.mu.RUnlock() | ||||
| 	m := c.entries | ||||
| 	for _, key := range keys { | ||||
| 		var err error | ||||
| 		m, err = m.FindEntry(key) | ||||
| 		if err != nil { | ||||
| 			return nil, &KeyNotFoundError{key} | ||||
| 		} | ||||
| 	} | ||||
| 	return m.Keys(), nil | ||||
| } | ||||
|  | ||||
| // Remove an entry from a Config. | ||||
| // The keys argument is a sequence of key values so that nested | ||||
| // entries can be removed. Removing an entry that has nested | ||||
| // entries removes those also. | ||||
| // Returns KeyNotFoundError if any of the keys can not be found. | ||||
| func (c *Config) Remove(keys []string) error { | ||||
| 	c.mu.Lock() | ||||
| 	defer c.mu.Unlock() | ||||
| 	m := c.entries | ||||
| 	for i := 0; i < len(keys)-1; i++ { | ||||
| 		var err error | ||||
| 		key := keys[i] | ||||
| 		m, err = m.FindEntry(key) | ||||
| 		if err != nil { | ||||
| 			return &KeyNotFoundError{key} | ||||
| 		} | ||||
| 	} | ||||
| 	err := m.RemoveEntry(keys[len(keys)-1]) | ||||
| 	if err != nil { | ||||
| 		return &KeyNotFoundError{keys[len(keys)-1]} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // Set a string value in a Config. | ||||
| // The keys argument is a sequence of key values so that nested | ||||
| // entries can be set. If any of the keys do not exist they will | ||||
| // be created. If the string value to be set is empty it will be | ||||
| // represented as null not an empty string when written. | ||||
| // | ||||
| //	var c *Config | ||||
| //	c.Set([]string{"key"}, "") | ||||
| //	Write(c) // writes `key: ` not `key: ""` | ||||
| func (c *Config) Set(keys []string, value string) { | ||||
| 	c.mu.Lock() | ||||
| 	defer c.mu.Unlock() | ||||
| 	m := c.entries | ||||
| 	for i := 0; i < len(keys)-1; i++ { | ||||
| 		key := keys[i] | ||||
| 		entry, err := m.FindEntry(key) | ||||
| 		if err != nil { | ||||
| 			entry = yamlmap.MapValue() | ||||
| 			m.AddEntry(key, entry) | ||||
| 		} | ||||
| 		m = entry | ||||
| 	} | ||||
| 	val := yamlmap.StringValue(value) | ||||
| 	if value == "" { | ||||
| 		val = yamlmap.NullValue() | ||||
| 	} | ||||
| 	m.SetEntry(keys[len(keys)-1], val) | ||||
| } | ||||
|  | ||||
| func (c *Config) deepCopy() *Config { | ||||
| 	return ReadFromString(c.entries.String()) | ||||
| } | ||||
|  | ||||
| // Read gh configuration files from the local file system and | ||||
| // returns a Config. A copy of the fallback configuration will | ||||
| // be returned when there are no configuration files to load. | ||||
| // If there are no configuration files and no fallback configuration | ||||
| // an empty configuration will be returned. | ||||
| var Read = func(fallback *Config) (*Config, error) { | ||||
| 	once.Do(func() { | ||||
| 		cfg, loadErr = load(generalConfigFile(), hostsConfigFile(), fallback) | ||||
| 	}) | ||||
| 	return cfg, loadErr | ||||
| } | ||||
|  | ||||
| // ReadFromString takes a yaml string and returns a Config. | ||||
| func ReadFromString(str string) *Config { | ||||
| 	m, _ := mapFromString(str) | ||||
| 	if m == nil { | ||||
| 		m = yamlmap.MapValue() | ||||
| 	} | ||||
| 	return &Config{entries: m} | ||||
| } | ||||
|  | ||||
| // Write gh configuration files to the local file system. | ||||
| // It will only write gh configuration files that have been modified | ||||
| // since last being read. | ||||
| func Write(c *Config) error { | ||||
| 	c.mu.Lock() | ||||
| 	defer c.mu.Unlock() | ||||
| 	hosts, err := c.entries.FindEntry("hosts") | ||||
| 	if err == nil && hosts.IsModified() { | ||||
| 		err := writeFile(hostsConfigFile(), []byte(hosts.String())) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		hosts.SetUnmodified() | ||||
| 	} | ||||
|  | ||||
| 	if c.entries.IsModified() { | ||||
| 		// Hosts gets written to a different file above so remove it | ||||
| 		// before writing and add it back in after writing. | ||||
| 		hostsMap, hostsErr := c.entries.FindEntry("hosts") | ||||
| 		if hostsErr == nil { | ||||
| 			_ = c.entries.RemoveEntry("hosts") | ||||
| 		} | ||||
| 		err := writeFile(generalConfigFile(), []byte(c.entries.String())) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		c.entries.SetUnmodified() | ||||
| 		if hostsErr == nil { | ||||
| 			c.entries.AddEntry("hosts", hostsMap) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func load(generalFilePath, hostsFilePath string, fallback *Config) (*Config, error) { | ||||
| 	generalMap, err := mapFromFile(generalFilePath) | ||||
| 	if err != nil && !os.IsNotExist(err) { | ||||
| 		if errors.Is(err, yamlmap.ErrInvalidYaml) || | ||||
| 			errors.Is(err, yamlmap.ErrInvalidFormat) { | ||||
| 			return nil, &InvalidConfigFileError{Path: generalFilePath, Err: err} | ||||
| 		} | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if generalMap == nil { | ||||
| 		generalMap = yamlmap.MapValue() | ||||
| 	} | ||||
|  | ||||
| 	hostsMap, err := mapFromFile(hostsFilePath) | ||||
| 	if err != nil && !os.IsNotExist(err) { | ||||
| 		if errors.Is(err, yamlmap.ErrInvalidYaml) || | ||||
| 			errors.Is(err, yamlmap.ErrInvalidFormat) { | ||||
| 			return nil, &InvalidConfigFileError{Path: hostsFilePath, Err: err} | ||||
| 		} | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if hostsMap != nil && !hostsMap.Empty() { | ||||
| 		generalMap.AddEntry("hosts", hostsMap) | ||||
| 		generalMap.SetUnmodified() | ||||
| 	} | ||||
|  | ||||
| 	if generalMap.Empty() && fallback != nil { | ||||
| 		return fallback.deepCopy(), nil | ||||
| 	} | ||||
|  | ||||
| 	return &Config{entries: generalMap}, nil | ||||
| } | ||||
|  | ||||
| func generalConfigFile() string { | ||||
| 	return filepath.Join(ConfigDir(), "config.yml") | ||||
| } | ||||
|  | ||||
| func hostsConfigFile() string { | ||||
| 	return filepath.Join(ConfigDir(), "hosts.yml") | ||||
| } | ||||
|  | ||||
| func mapFromFile(filename string) (*yamlmap.Map, error) { | ||||
| 	data, err := readFile(filename) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return yamlmap.Unmarshal(data) | ||||
| } | ||||
|  | ||||
| func mapFromString(str string) (*yamlmap.Map, error) { | ||||
| 	return yamlmap.Unmarshal([]byte(str)) | ||||
| } | ||||
|  | ||||
| // Config path precedence: GH_CONFIG_DIR, XDG_CONFIG_HOME, AppData (windows only), HOME. | ||||
| func ConfigDir() string { | ||||
| 	var path string | ||||
| 	if a := os.Getenv(ghConfigDir); a != "" { | ||||
| 		path = a | ||||
| 	} else if b := os.Getenv(xdgConfigHome); b != "" { | ||||
| 		path = filepath.Join(b, "gh") | ||||
| 	} else if c := os.Getenv(appData); runtime.GOOS == "windows" && c != "" { | ||||
| 		path = filepath.Join(c, "GitHub CLI") | ||||
| 	} else { | ||||
| 		d, _ := os.UserHomeDir() | ||||
| 		path = filepath.Join(d, ".config", "gh") | ||||
| 	} | ||||
| 	return path | ||||
| } | ||||
|  | ||||
| // State path precedence: XDG_STATE_HOME, LocalAppData (windows only), HOME. | ||||
| func StateDir() string { | ||||
| 	var path string | ||||
| 	if a := os.Getenv(xdgStateHome); a != "" { | ||||
| 		path = filepath.Join(a, "gh") | ||||
| 	} else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" { | ||||
| 		path = filepath.Join(b, "GitHub CLI") | ||||
| 	} else { | ||||
| 		c, _ := os.UserHomeDir() | ||||
| 		path = filepath.Join(c, ".local", "state", "gh") | ||||
| 	} | ||||
| 	return path | ||||
| } | ||||
|  | ||||
| // Data path precedence: XDG_DATA_HOME, LocalAppData (windows only), HOME. | ||||
| func DataDir() string { | ||||
| 	var path string | ||||
| 	if a := os.Getenv(xdgDataHome); a != "" { | ||||
| 		path = filepath.Join(a, "gh") | ||||
| 	} else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" { | ||||
| 		path = filepath.Join(b, "GitHub CLI") | ||||
| 	} else { | ||||
| 		c, _ := os.UserHomeDir() | ||||
| 		path = filepath.Join(c, ".local", "share", "gh") | ||||
| 	} | ||||
| 	return path | ||||
| } | ||||
|  | ||||
| // Cache path precedence: XDG_CACHE_HOME, LocalAppData (windows only), HOME, legacy gh-cli-cache. | ||||
| func CacheDir() string { | ||||
| 	if a := os.Getenv(xdgCacheHome); a != "" { | ||||
| 		return filepath.Join(a, "gh") | ||||
| 	} else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" { | ||||
| 		return filepath.Join(b, "GitHub CLI") | ||||
| 	} else if c, err := os.UserHomeDir(); err == nil { | ||||
| 		return filepath.Join(c, ".cache", "gh") | ||||
| 	} else { | ||||
| 		// Note that this has a minor security issue because /tmp is world-writeable. | ||||
| 		// As such, it is possible for other users on a shared system to overwrite cached data. | ||||
| 		// The practical risk of this is low, but it's worth calling out as a risk. | ||||
| 		// I've included this here for backwards compatibility but we should consider removing it. | ||||
| 		return filepath.Join(os.TempDir(), "gh-cli-cache") | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func readFile(filename string) ([]byte, error) { | ||||
| 	f, err := os.Open(filename) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer f.Close() | ||||
| 	data, err := io.ReadAll(f) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return data, nil | ||||
| } | ||||
|  | ||||
| func writeFile(filename string, data []byte) (writeErr error) { | ||||
| 	if writeErr = os.MkdirAll(filepath.Dir(filename), 0771); writeErr != nil { | ||||
| 		return | ||||
| 	} | ||||
| 	var file *os.File | ||||
| 	if file, writeErr = os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600); writeErr != nil { | ||||
| 		return | ||||
| 	} | ||||
| 	defer func() { | ||||
| 		if err := file.Close(); writeErr == nil && err != nil { | ||||
| 			writeErr = err | ||||
| 		} | ||||
| 	}() | ||||
| 	_, writeErr = file.Write(data) | ||||
| 	return | ||||
| } | ||||
							
								
								
									
										32
									
								
								vendor/github.com/cli/go-gh/v2/pkg/config/errors.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								vendor/github.com/cli/go-gh/v2/pkg/config/errors.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| package config | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| ) | ||||
|  | ||||
| // InvalidConfigFileError represents an error when trying to read a config file. | ||||
| type InvalidConfigFileError struct { | ||||
| 	Path string | ||||
| 	Err  error | ||||
| } | ||||
|  | ||||
| // Allow InvalidConfigFileError to satisfy error interface. | ||||
| func (e *InvalidConfigFileError) Error() string { | ||||
| 	return fmt.Sprintf("invalid config file %s: %s", e.Path, e.Err) | ||||
| } | ||||
|  | ||||
| // Allow InvalidConfigFileError to be unwrapped. | ||||
| func (e *InvalidConfigFileError) Unwrap() error { | ||||
| 	return e.Err | ||||
| } | ||||
|  | ||||
| // KeyNotFoundError represents an error when trying to find a config key | ||||
| // that does not exist. | ||||
| type KeyNotFoundError struct { | ||||
| 	Key string | ||||
| } | ||||
|  | ||||
| // Allow KeyNotFoundError to satisfy error interface. | ||||
| func (e *KeyNotFoundError) Error() string { | ||||
| 	return fmt.Sprintf("could not find key %q", e.Key) | ||||
| } | ||||
							
								
								
									
										25
									
								
								vendor/github.com/cli/safeexec/LICENSE
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								vendor/github.com/cli/safeexec/LICENSE
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| BSD 2-Clause License | ||||
|  | ||||
| Copyright (c) 2020, GitHub Inc. | ||||
| All rights reserved. | ||||
|  | ||||
| Redistribution and use in source and binary forms, with or without | ||||
| modification, are permitted provided that the following conditions are met: | ||||
|  | ||||
| 1. Redistributions of source code must retain the above copyright notice, this | ||||
|    list of conditions and the following disclaimer. | ||||
|  | ||||
| 2. Redistributions in binary form must reproduce the above copyright notice, | ||||
|    this list of conditions and the following disclaimer in the documentation | ||||
|    and/or other materials provided with the distribution. | ||||
|  | ||||
| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | ||||
| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | ||||
| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | ||||
| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE | ||||
| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL | ||||
| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR | ||||
| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER | ||||
| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, | ||||
| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||||
| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||||
							
								
								
									
										40
									
								
								vendor/github.com/cli/safeexec/README.md
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								vendor/github.com/cli/safeexec/README.md
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| # safeexec | ||||
|  | ||||
| A Go module that provides a safer alternative to `exec.LookPath()` on Windows. | ||||
|  | ||||
| The following, relatively common approach to running external commands has a subtle vulnerability on Windows: | ||||
| ```go | ||||
| import "os/exec" | ||||
|  | ||||
| func gitStatus() error { | ||||
|     // On Windows, this will result in `.\git.exe` or `.\git.bat` being executed | ||||
|     // if either were found in the current working directory. | ||||
|     cmd := exec.Command("git", "status") | ||||
|     return cmd.Run() | ||||
| } | ||||
| ``` | ||||
|  | ||||
| Searching the current directory (surprising behavior) before searching folders listed in the PATH environment variable (expected behavior) seems to be intended in Go and unlikely to be changed: https://github.com/golang/go/issues/38736 | ||||
|  | ||||
| Since Go does not provide a version of [`exec.LookPath()`](https://golang.org/pkg/os/exec/#LookPath) that only searches PATH and does not search the current working directory, this module provides a `LookPath` function that works consistently across platforms. | ||||
|  | ||||
| Example use: | ||||
| ```go | ||||
| import ( | ||||
|     "os/exec" | ||||
|     "github.com/cli/safeexec" | ||||
| ) | ||||
|  | ||||
| func gitStatus() error { | ||||
|     gitBin, err := safeexec.LookPath("git") | ||||
|     if err != nil { | ||||
|         return err | ||||
|     } | ||||
|     cmd := exec.Command(gitBin, "status") | ||||
|     return cmd.Run() | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## TODO | ||||
|  | ||||
| Ideally, this module would also provide `exec.Command()` and `exec.CommandContext()` equivalents that delegate to the patched version of `LookPath`. However, this doesn't seem possible since `LookPath` may return an error, while `exec.Command/CommandContext()` themselves do not return an error. In the standard library, the resulting `exec.Cmd` struct stores the LookPath error in a private field, but that functionality isn't available to us. | ||||
							
								
								
									
										9
									
								
								vendor/github.com/cli/safeexec/lookpath.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								vendor/github.com/cli/safeexec/lookpath.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| // +build !windows | ||||
|  | ||||
| package safeexec | ||||
|  | ||||
| import "os/exec" | ||||
|  | ||||
| func LookPath(file string) (string, error) { | ||||
| 	return exec.LookPath(file) | ||||
| } | ||||
							
								
								
									
										120
									
								
								vendor/github.com/cli/safeexec/lookpath_windows.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								vendor/github.com/cli/safeexec/lookpath_windows.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,120 @@ | ||||
| // Copyright (c) 2009 The Go Authors. All rights reserved. | ||||
| // | ||||
| // Redistribution and use in source and binary forms, with or without | ||||
| // modification, are permitted provided that the following conditions are | ||||
| // met: | ||||
| // | ||||
| //    * Redistributions of source code must retain the above copyright | ||||
| // notice, this list of conditions and the following disclaimer. | ||||
| //    * Redistributions in binary form must reproduce the above | ||||
| // copyright notice, this list of conditions and the following disclaimer | ||||
| // in the documentation and/or other materials provided with the | ||||
| // distribution. | ||||
| //    * Neither the name of Google Inc. nor the names of its | ||||
| // contributors may be used to endorse or promote products derived from | ||||
| // this software without specific prior written permission. | ||||
| // | ||||
| // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | ||||
| // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | ||||
| // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | ||||
| // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | ||||
| // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | ||||
| // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | ||||
| // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | ||||
| // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | ||||
| // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | ||||
| // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | ||||
| // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||||
|  | ||||
| // Package safeexec provides alternatives for exec package functions to avoid | ||||
| // accidentally executing binaries found in the current working directory on | ||||
| // Windows. | ||||
| package safeexec | ||||
|  | ||||
| import ( | ||||
| 	"os" | ||||
| 	"os/exec" | ||||
| 	"path/filepath" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| func chkStat(file string) error { | ||||
| 	d, err := os.Stat(file) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if d.IsDir() { | ||||
| 		return os.ErrPermission | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func hasExt(file string) bool { | ||||
| 	i := strings.LastIndex(file, ".") | ||||
| 	if i < 0 { | ||||
| 		return false | ||||
| 	} | ||||
| 	return strings.LastIndexAny(file, `:\/`) < i | ||||
| } | ||||
|  | ||||
| func findExecutable(file string, exts []string) (string, error) { | ||||
| 	if len(exts) == 0 { | ||||
| 		return file, chkStat(file) | ||||
| 	} | ||||
| 	if hasExt(file) { | ||||
| 		if chkStat(file) == nil { | ||||
| 			return file, nil | ||||
| 		} | ||||
| 	} | ||||
| 	for _, e := range exts { | ||||
| 		if f := file + e; chkStat(f) == nil { | ||||
| 			return f, nil | ||||
| 		} | ||||
| 	} | ||||
| 	return "", os.ErrNotExist | ||||
| } | ||||
|  | ||||
| // LookPath searches for an executable named file in the | ||||
| // directories named by the PATH environment variable. | ||||
| // If file contains a slash, it is tried directly and the PATH is not consulted. | ||||
| // LookPath also uses PATHEXT environment variable to match | ||||
| // a suitable candidate. | ||||
| // The result may be an absolute path or a path relative to the current directory. | ||||
| func LookPath(file string) (string, error) { | ||||
| 	var exts []string | ||||
| 	x := os.Getenv(`PATHEXT`) | ||||
| 	if x != "" { | ||||
| 		for _, e := range strings.Split(strings.ToLower(x), `;`) { | ||||
| 			if e == "" { | ||||
| 				continue | ||||
| 			} | ||||
| 			if e[0] != '.' { | ||||
| 				e = "." + e | ||||
| 			} | ||||
| 			exts = append(exts, e) | ||||
| 		} | ||||
| 	} else { | ||||
| 		exts = []string{".com", ".exe", ".bat", ".cmd"} | ||||
| 	} | ||||
|  | ||||
| 	if strings.ContainsAny(file, `:\/`) { | ||||
| 		if f, err := findExecutable(file, exts); err == nil { | ||||
| 			return f, nil | ||||
| 		} else { | ||||
| 			return "", &exec.Error{file, err} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// https://github.com/golang/go/issues/38736 | ||||
| 	// if f, err := findExecutable(filepath.Join(".", file), exts); err == nil { | ||||
| 	// 	return f, nil | ||||
| 	// } | ||||
|  | ||||
| 	path := os.Getenv("path") | ||||
| 	for _, dir := range filepath.SplitList(path) { | ||||
| 		if f, err := findExecutable(filepath.Join(dir, file), exts); err == nil { | ||||
| 			return f, nil | ||||
| 		} | ||||
| 	} | ||||
| 	return "", &exec.Error{file, exec.ErrNotFound} | ||||
| } | ||||
							
								
								
									
										2
									
								
								vendor/github.com/mattn/go-colorable/noncolorable.go
									
									
									
										generated
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								vendor/github.com/mattn/go-colorable/noncolorable.go
									
									
									
										generated
									
									
										vendored
									
									
								
							| @@ -42,7 +42,6 @@ loop: | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		var buf bytes.Buffer | ||||
| 		for { | ||||
| 			c, err := er.ReadByte() | ||||
| 			if err != nil { | ||||
| @@ -51,7 +50,6 @@ loop: | ||||
| 			if ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || c == '@' { | ||||
| 				break | ||||
| 			} | ||||
| 			buf.Write([]byte(string(c))) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
|   | ||||
							
								
								
									
										5
									
								
								vendor/github.com/mattn/go-isatty/isatty_bsd.go
									
									
									
										generated
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								vendor/github.com/mattn/go-isatty/isatty_bsd.go
									
									
									
										generated
									
									
										vendored
									
									
								
							| @@ -1,6 +1,7 @@ | ||||
| //go:build (darwin || freebsd || openbsd || netbsd || dragonfly) && !appengine | ||||
| // +build darwin freebsd openbsd netbsd dragonfly | ||||
| //go:build (darwin || freebsd || openbsd || netbsd || dragonfly || hurd) && !appengine && !tinygo | ||||
| // +build darwin freebsd openbsd netbsd dragonfly hurd | ||||
| // +build !appengine | ||||
| // +build !tinygo | ||||
|  | ||||
| package isatty | ||||
|  | ||||
|   | ||||
							
								
								
									
										5
									
								
								vendor/github.com/mattn/go-isatty/isatty_others.go
									
									
									
										generated
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								vendor/github.com/mattn/go-isatty/isatty_others.go
									
									
									
										generated
									
									
										vendored
									
									
								
							| @@ -1,5 +1,6 @@ | ||||
| //go:build appengine || js || nacl || wasm | ||||
| // +build appengine js nacl wasm | ||||
| //go:build (appengine || js || nacl || tinygo || wasm) && !windows | ||||
| // +build appengine js nacl tinygo wasm | ||||
| // +build !windows | ||||
|  | ||||
| package isatty | ||||
|  | ||||
|   | ||||
							
								
								
									
										3
									
								
								vendor/github.com/mattn/go-isatty/isatty_tcgets.go
									
									
									
										generated
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								vendor/github.com/mattn/go-isatty/isatty_tcgets.go
									
									
									
										generated
									
									
										vendored
									
									
								
							| @@ -1,6 +1,7 @@ | ||||
| //go:build (linux || aix || zos) && !appengine | ||||
| //go:build (linux || aix || zos) && !appengine && !tinygo | ||||
| // +build linux aix zos | ||||
| // +build !appengine | ||||
| // +build !tinygo | ||||
|  | ||||
| package isatty | ||||
|  | ||||
|   | ||||
							
								
								
									
										17
									
								
								vendor/modules.txt
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										17
									
								
								vendor/modules.txt
									
									
									
									
										vendored
									
									
								
							| @@ -48,6 +48,15 @@ github.com/bahlo/generic-list-go | ||||
| # github.com/buger/jsonparser v1.1.1 | ||||
| ## explicit; go 1.13 | ||||
| github.com/buger/jsonparser | ||||
| # github.com/cli/go-gh/v2 v2.9.0 | ||||
| ## explicit; go 1.21 | ||||
| github.com/cli/go-gh/v2/internal/set | ||||
| github.com/cli/go-gh/v2/internal/yamlmap | ||||
| github.com/cli/go-gh/v2/pkg/auth | ||||
| github.com/cli/go-gh/v2/pkg/config | ||||
| # github.com/cli/safeexec v1.0.0 | ||||
| ## explicit; go 1.15 | ||||
| github.com/cli/safeexec | ||||
| # github.com/clipperhouse/uax29/v2 v2.2.0 | ||||
| ## explicit; go 1.18 | ||||
| github.com/clipperhouse/uax29/v2/graphemes | ||||
| @@ -265,11 +274,11 @@ github.com/lucasb-eyer/go-colorful | ||||
| ## explicit; go 1.12 | ||||
| github.com/mailru/easyjson/buffer | ||||
| github.com/mailru/easyjson/jwriter | ||||
| # github.com/mattn/go-colorable v0.1.11 | ||||
| ## explicit; go 1.13 | ||||
| # github.com/mattn/go-colorable v0.1.13 | ||||
| ## explicit; go 1.15 | ||||
| github.com/mattn/go-colorable | ||||
| # github.com/mattn/go-isatty v0.0.14 | ||||
| ## explicit; go 1.12 | ||||
| # github.com/mattn/go-isatty v0.0.20 | ||||
| ## explicit; go 1.15 | ||||
| github.com/mattn/go-isatty | ||||
| # github.com/mattn/go-runewidth v0.0.19 | ||||
| ## explicit; go 1.20 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user