add tea pulls checkout command (#93) #103

Closed
noerw wants to merge 4 commits from noerw:issue-93/pulls-checkout into main
5 changed files with 202 additions and 1 deletions
Showing only changes of commit de03aea30b - Show all commits

View File

@ -5,21 +5,29 @@
package cmd
import (
"fmt"
"log"
"strconv"
"strings"
"code.gitea.io/sdk/gitea"
local_git "code.gitea.io/tea/modules/git"
"gopkg.in/src-d/go-git.v4"
"github.com/urfave/cli/v2"
)
// CmdPulls represents to login a gitea server.
// CmdPulls is the main command to operate on PRs
var CmdPulls = cli.Command{
Name: "pulls",
Usage: "List open pull requests",
Description: `List open pull requests`,
Action: runPulls,
Flags: AllDefaultFlags,
Subcommands: []*cli.Command{
&CmdPullsCheckout,
},
}
func runPulls(ctx *cli.Context) error {
@ -70,3 +78,79 @@ func runPulls(ctx *cli.Context) error {
return nil
}
// CmdPullsCheckout is a command to locally checkout the given PR
var CmdPullsCheckout = cli.Command{
Name: "checkout",
Usage: "Locally check out the given PR",
Description: `Locally check out the given PR`,
Action: runPullsCheckout,
ArgsUsage: "<pull index>",
Flags: AllDefaultFlags,
}
func runPullsCheckout(ctx *cli.Context) error {
login, owner, repo := initCommand()
if ctx.Args().Len() != 1 {
log.Fatal("Must specify a PR index")
}
// fetch PR source-repo & -branch from gitea
idx, err := argToIndex(ctx.Args().First())
if err != nil {
return err
}
pr, err := login.Client().GetPullRequest(owner, repo, idx)
if err != nil {
return err
}
remoteURL := pr.Head.Repository.CloneURL
remoteBranchName := pr.Head.Ref
// open local git repo
localRepo, err := local_git.RepoForWorkdir()
if err != nil {
return nil
}
// verify related remote is in local repo, otherwise add it
newRemoteName := pr.Head.Repository.Owner.UserName
localRemote, err := localRepo.GetOrCreateRemote(remoteURL, newRemoteName)
if err != nil {
return err
}
localRemoteName := localRemote.Config().Name
localBranchName := fmt.Sprintf("pull-%v-%v", idx, remoteBranchName)
// fetch remote
fmt.Printf("Fetching PR %v (head %s:%s) from remote '%s'\n",
idx, remoteURL, remoteBranchName, localRemoteName)
err = localRemote.Fetch(&git.FetchOptions{})
if err == git.NoErrAlreadyUpToDate {
fmt.Println(err)
} else if err != nil {
return err
}
// checkout local branch
fmt.Printf("Creating branch '%s'\n", localBranchName)
err = localRepo.TeaCreateBranch(localBranchName, remoteBranchName, localRemoteName)
if err == git.ErrBranchExists {
fmt.Println(err)
} else if err != nil {
return err
}
fmt.Printf("Checking out PR %v\n", idx)
err = localRepo.TeaCheckout(localBranchName)
return err
}
func argToIndex(arg string) (int64, error) {
if strings.HasPrefix(arg, "#") {
arg = arg[1:]
}
return strconv.ParseInt(arg, 10, 64)
}

42
modules/git/branch.go Normal file
View File

@ -0,0 +1,42 @@
package git
import (
"gopkg.in/src-d/go-git.v4"
git_config "gopkg.in/src-d/go-git.v4/config"
git_plumbing "gopkg.in/src-d/go-git.v4/plumbing"
)
// TeaCreateBranch creates a new branch in the repo, tracking from another branch.
// If remoteName is not-null, a remote branch is tracked.
func (r TeaRepo) TeaCreateBranch(localBranchName, remoteBranchName, remoteName string) error {
remoteBranchRefName := git_plumbing.NewBranchReferenceName(remoteBranchName)
err := r.CreateBranch(&git_config.Branch{
Name: localBranchName,
Merge: remoteBranchRefName,
Remote: remoteName,
})
if err != nil {
return err
}
// serialize the branch to .git/refs/heads (otherwise branch is only defined
// in .git/.config)
localBranchRefName := git_plumbing.NewBranchReferenceName(localBranchName)
remoteBranchRef, err := r.Storer.Reference(remoteBranchRefName)
if err != nil {
return nil
}
localHashRef := git_plumbing.NewHashReference(localBranchRefName, remoteBranchRef.Hash())
r.Storer.SetReference(localHashRef)
return nil
}
// TeaCheckout checks out the given branch in the worktree.
func (r TeaRepo) TeaCheckout(branchName string) error {
tree, err := r.Worktree()
if err != nil {
return nil
}
localBranchRefName := git_plumbing.NewBranchReferenceName(branchName)
return tree.Checkout(&git.CheckoutOptions{Branch: localBranchRefName})
}

47
modules/git/remote.go Normal file
View File

@ -0,0 +1,47 @@
package git
import (
"gopkg.in/src-d/go-git.v4"
git_config "gopkg.in/src-d/go-git.v4/config"
)
// GetOrCreateRemote tries to match a Remote of the repo via the given URL.
// If no match is found, a new Remote with `newRemoteName` is created.
// Matching is based on the normalized URL, accepting different protocols.
func (r TeaRepo) GetOrCreateRemote(remoteURL, newRemoteName string) (*git.Remote, error) {
repoURL, err := ParseURL(remoteURL)
if err != nil {
return nil, err
}
remotes, err := r.Remotes()
if err != nil {
return nil, err
}
var localRemote *git.Remote = nil
for _, r := range remotes {
for _, u := range r.Config().URLs {
remoteURL, _ := ParseURL(u)
if remoteURL.Host == repoURL.Host && remoteURL.Path == repoURL.Path {
localRemote = r
break
}
}
if localRemote != nil {
break
}
}
// if no match found, create a new remote
if localRemote == nil {
localRemote, err = r.CreateRemote(&git_config.RemoteConfig{
Name: newRemoteName,
URLs: []string{remoteURL},
})
if err != nil {
return nil, err
}
}
return localRemote, nil
}

23
modules/git/repo.go Normal file
View File

@ -0,0 +1,23 @@
package git
import (
"gopkg.in/src-d/go-git.v4"
)
// TeaRepo is a go-git Repository, with an extended high level interface.
type TeaRepo struct {
*git.Repository
}
// RepoForWorkdir tries to open the git repository in the local directory
// for reading or modification.
func RepoForWorkdir() (*TeaRepo, error) {
repo, err := git.PlainOpenWithOptions("./", &git.PlainOpenOptions{
DetectDotGit: true,
})
if err != nil {
return nil, err
}
return &TeaRepo{repo}, nil
}

View File

@ -36,6 +36,11 @@ func (p *URLParser) Parse(rawURL string) (u *url.URL, err error) {
u.Path = strings.TrimPrefix(u.Path, "/")
}
// .git suffix is optional and breaks normalization
if strings.HasSuffix(u.Path, ".git") {
u.Path = strings.TrimSuffix(u.Path, ".git")
}
return
}