Skip to content

Commit

Permalink
feat: support to checkout github pr (#12)
Browse files Browse the repository at this point in the history
* feat: support to checkout github pr

* fix the auth issues

* support to checkout branch

* add more unit tests

Co-authored-by: rick <[email protected]>
  • Loading branch information
LinuxSuRen and LinuxSuRen authored Jan 5, 2023
1 parent 53ddb16 commit 8052720
Show file tree
Hide file tree
Showing 7 changed files with 212 additions and 50 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
bin/
dist/
.idea/
coverage.out
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ copy: build
cp bin/gogit /usr/local/bin
test:
go test ./... -coverprofile coverage.out
pre-commit: test
goreleaser:
goreleaser build --snapshot --rm-dist
image:
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@
* Gitlab (public or private)

## Usage

### Checkout to branch or PR
Ideally, `gogit` could checkout to your branch or PR in any kind of git repository.

You can run the following command in a git repository directory:

```shell
gogit checkout --pr 1
```

### Send status to Git Provider
Below is an example of sending build status to a private Gitlab server:

```shell
Expand Down
157 changes: 107 additions & 50 deletions cmd/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@ package cmd

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
"github.com/spf13/cobra"
"os"
"path/filepath"
)

func newCheckoutCommand() (c *cobra.Command) {
opt := &checkoutOption{}

c = &cobra.Command{
Use: "checkout",
Short: "Clond and checkout the git repository with branch, tag, or pull request",
Aliases: []string{"co"},
Short: "Clone and checkout the git repository with branch, tag, or pull request",
Example: "gogit checkout https://github.com/linuxsuren/gogit",
PreRunE: opt.preRunE,
RunE: opt.runE,
Expand All @@ -25,6 +29,8 @@ func newCheckoutCommand() (c *cobra.Command) {
flags := c.Flags()
flags.StringVarP(&opt.url, "url", "", "", "The git repository URL")
flags.StringVarP(&opt.remote, "remote", "", "origin", "The remote name")
flags.StringVarP(&opt.sshPrivateKey, "ssh-private-key", "", "$HOME/.ssh/id_rsa",
"The SSH private key file path")
flags.StringVarP(&opt.branch, "branch", "", "master", "The branch want to checkout")
flags.StringVarP(&opt.tag, "tag", "", "", "The tag want to checkout")
flags.IntVarP(&opt.pr, "pr", "", -1, "The pr number want to checkout, -1 means do nothing")
Expand All @@ -45,70 +51,120 @@ func (o *checkoutOption) runE(c *cobra.Command, args []string) (err error) {
if repoDir, err = filepath.Abs(o.target); err != nil {
return
}
rsa := os.ExpandEnv("$HOME/.ssh/id_rsa")

var publicKeys *ssh.PublicKeys
if publicKeys, err = ssh.NewPublicKeysFromFile("git", rsa, ""); err != nil {
var gitAuth transport.AuthMethod
if gitAuth, err = o.getAuth(o.url); err != nil {
return
}

if _, err = git.PlainClone(repoDir, false, &git.CloneOptions{
RemoteName: o.remote,
Auth: publicKeys,
URL: o.url,
ReferenceName: plumbing.NewBranchReferenceName(o.branch),
Progress: c.OutOrStdout(),
}); err != nil {
err = fmt.Errorf("failed to clone git repository '%s' into '%s', error: %v", o.url, repoDir, err)
var repo *git.Repository
if _, serr := os.Stat(filepath.Join(repoDir, ".git")); serr != nil {
if repo, err = git.PlainCloneContext(c.Context(), repoDir, false, &git.CloneOptions{
RemoteName: o.remote,
Auth: gitAuth,
URL: o.url,
ReferenceName: plumbing.NewBranchReferenceName(o.branch),
Progress: c.OutOrStdout(),
}); err != nil {
err = fmt.Errorf("failed to clone git repository '%s' into '%s', error: %v", o.url, repoDir, err)
return
}
} else if repo, err = git.PlainOpen(repoDir); err != nil {
return
}

var repo *git.Repository
if repo, err = git.PlainOpen(repoDir); err == nil {
var wd *git.Worktree

if wd, err = repo.Worktree(); err == nil {
if o.tag != "" {
if err = wd.Checkout(&git.CheckoutOptions{
Branch: plumbing.NewTagReferenceName(o.tag),
}); err != nil {
err = fmt.Errorf("unable to checkout git branch: %s, error: %v", o.tag, err)
return
}
var wd *git.Worktree
var remotes []*git.Remote

if remotes, err = repo.Remotes(); err != nil {
return
}

remoteURL := remotes[0].Config().URLs[0]
kind := detectGitKind(remoteURL)
// need to get auth again if the repo was exist
if gitAuth, err = o.getAuth(remoteURL); err != nil {
return
}

if wd, err = repo.Worktree(); err == nil {
if c.Flags().Changed("branch") {
c.Printf("Switched to branch '%s'\n", o.branch)

if err = wd.Checkout(&git.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(o.branch),
}); err != nil {
err = fmt.Errorf("unable to checkout git branch: %s, error: %v", o.branch, err)
return
}
}

if o.pr > 0 {
// TODO add GitHub support, see also https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally?gt
if err = repo.Fetch(&git.FetchOptions{
RemoteName: o.remote,
Auth: publicKeys,
Progress: c.OutOrStdout(),
RefSpecs: []config.RefSpec{config.RefSpec(fmt.Sprintf("refs/merge-requests/%d/head:mr-%d", o.pr, o.pr))},
}); err != nil && err != git.NoErrAlreadyUpToDate {
err = fmt.Errorf("failed to fetch '%s', error: %v", o.remote, err)
return
}

if err = wd.Checkout(&git.CheckoutOptions{
Create: true,
Branch: plumbing.NewBranchReferenceName(fmt.Sprintf("mr-%d", o.pr)),
}); err != nil {
err = fmt.Errorf("unable to checkout git branch: %s, error: %v", o.tag, err)
return
}
if o.tag != "" {
if err = wd.Checkout(&git.CheckoutOptions{
Branch: plumbing.NewTagReferenceName(o.tag),
}); err != nil {
err = fmt.Errorf("unable to checkout git tag: %s, error: %v", o.tag, err)
return
}
}

var head *plumbing.Reference
if head, err = repo.Head(); err == nil {
if o.versionOutput != "" {
os.WriteFile(o.versionOutput, []byte(head.Name().Short()), 0444)
}
if o.pr > 0 {
if err = repo.Fetch(&git.FetchOptions{
RemoteName: o.remote,
Auth: gitAuth,
Progress: c.OutOrStdout(),
RefSpecs: []config.RefSpec{config.RefSpec(prRef(o.pr, kind))},
}); err != nil && err != git.NoErrAlreadyUpToDate {
err = fmt.Errorf("failed to fetch '%s', error: %v", o.remote, err)
return
}

if err = wd.Checkout(&git.CheckoutOptions{
Create: true,
Branch: plumbing.NewBranchReferenceName(fmt.Sprintf("pr-%d", o.pr)),
}); err != nil && !strings.Contains(err.Error(), "already exists") {
err = fmt.Errorf("unable to checkout git branch: %s, error: %v", o.tag, err)
return
}
}

var head *plumbing.Reference
if head, err = repo.Head(); err == nil {
if o.versionOutput != "" {
err = os.WriteFile(o.versionOutput, []byte(head.Name().Short()), 0444)
}
}
}
return
}

func (o *checkoutOption) getAuth(remote string) (auth transport.AuthMethod, err error) {
if strings.HasPrefix(remote, "git@") {
rsa := os.ExpandEnv(o.sshPrivateKey)
auth, err = ssh.NewPublicKeysFromFile("git", rsa, "")
}
return
}

func detectGitKind(gitURL string) (kind string) {
kind = "gitlab"
if strings.Contains(gitURL, "github.com") {
kind = "github"
}
return
}

// see also https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally?gt
func prRef(pr int, kind string) (ref string) {
switch kind {
case "gitlab":
ref = fmt.Sprintf("refs/merge-requests/%d/head:pr-%d", pr, pr)
case "github":
ref = fmt.Sprintf("refs/pull/%d/head:pr-%d", pr, pr)
}
return
}

type checkoutOption struct {
url string
remote string
Expand All @@ -117,4 +173,5 @@ type checkoutOption struct {
pr int
target string
versionOutput string
sshPrivateKey string
}
86 changes: 86 additions & 0 deletions cmd/checkout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package cmd

import (
"testing"

"github.com/stretchr/testify/assert"
)

func Test_prRef(t *testing.T) {
type args struct {
pr int
kind string
}
tests := []struct {
name string
args args
wantRef string
}{{
name: "gitlab",
args: args{
pr: 1,
kind: "gitlab",
},
wantRef: "refs/merge-requests/1/head:pr-1",
}, {
name: "unknown",
args: args{
pr: 1,
kind: "unknown",
},
wantRef: "",
}, {
name: "github",
args: args{
pr: 1,
kind: "github",
},
wantRef: "refs/pull/1/head:pr-1",
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.wantRef, prRef(tt.args.pr, tt.args.kind), "prRef(%v, %v)", tt.args.pr, tt.args.kind)
})
}
}

func Test_detectGitKind(t *testing.T) {
type args struct {
gitURL string
}
tests := []struct {
name string
args args
wantKind string
}{{
name: "github",
args: args{
gitURL: "https://github.com/linuxsuren/gogit",
},
wantKind: "github",
}, {
name: "gitlab",
args: args{
gitURL: "[email protected]:demo/test.git",
},
wantKind: "gitlab",
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.wantKind, detectGitKind(tt.args.gitURL), "detectGitKind(%v)", tt.args.gitURL)
})
}
}

func TestGetAuth(t *testing.T) {
opt := &checkoutOption{
sshPrivateKey: "/tmp",
}
auth, err := opt.getAuth("[email protected]")
assert.Nil(t, auth)
assert.NotNil(t, err)

auth, err = opt.getAuth("fake.com")
assert.Nil(t, auth)
assert.Nil(t, err)
}
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,20 @@ require (
github.com/imdario/mergo v0.3.12 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect
github.com/kr/pretty v0.2.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/sergi/go-diff v1.1.0 // indirect
github.com/xanzy/ssh-agent v0.3.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)

require (
code.gitea.io/sdk/gitea v0.14.0 // indirect
github.com/bluekeyes/go-gitdiff v0.4.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-git/go-git-fixtures/v4 v4.3.1
github.com/go-git/go-git/v5 v5.4.2
github.com/golang/protobuf v1.5.2 // indirect
github.com/hashicorp/go-version v1.3.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Ai
github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8=
github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
github.com/go-git/go-git-fixtures/v4 v4.3.1 h1:y5z6dd3qi8Hl+stezc8p3JxDkoTRqMAlKnXHuzrfjTQ=
github.com/go-git/go-git-fixtures/v4 v4.3.1/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo=
github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4=
github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
Expand Down

0 comments on commit 8052720

Please sign in to comment.