diff --git a/api/queries_repo.go b/api/queries_repo.go index 5a5061eda..f52d9ddd8 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "encoding/base64" "encoding/json" "errors" @@ -12,6 +13,7 @@ import ( "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/utils" + "github.com/shurcooL/githubv4" ) // Repository contains information about a GitHub repo @@ -93,6 +95,37 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { return &result.Repository, nil } +// RepoParent finds out the parent repository of a fork +func RepoParent(client *Client, repo ghrepo.Interface) (ghrepo.Interface, error) { + var query struct { + Repository struct { + Parent *struct { + Name string + Owner struct { + Login string + } + } + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + } + + v4 := githubv4.NewClient(client.http) + err := v4.Query(context.Background(), &query, variables) + if err != nil { + return nil, err + } + if query.Repository.Parent == nil { + return nil, nil + } + + parent := ghrepo.New(query.Repository.Parent.Owner.Login, query.Repository.Parent.Name) + return parent, nil +} + // RepoNetworkResult describes the relationship between related repositories type RepoNetworkResult struct { ViewerLogin string diff --git a/command/repo.go b/command/repo.go index 959cfbaec..4b94812d5 100644 --- a/command/repo.go +++ b/command/repo.go @@ -93,6 +93,28 @@ func repoClone(cmd *cobra.Command, args []string) error { cloneURL = fmt.Sprintf("https://github.com/%s.git", cloneURL) } + var repo ghrepo.Interface + var parentRepo ghrepo.Interface + + // TODO: consider caching and reusing `git.ParseSSHConfig().Translator()` + // here to handle hostname aliases in SSH remotes + if u, err := git.ParseURL(cloneURL); err == nil { + repo, _ = ghrepo.FromURL(u) + } + + if repo != nil { + ctx := contextForCommand(cmd) + apiClient, err := apiClientForContext(ctx) + if err != nil { + return err + } + + parentRepo, err = api.RepoParent(apiClient, repo) + if err != nil { + return err + } + } + cloneArgs := []string{"clone"} cloneArgs = append(cloneArgs, args[1:]...) cloneArgs = append(cloneArgs, cloneURL) @@ -101,7 +123,26 @@ func repoClone(cmd *cobra.Command, args []string) error { cloneCmd.Stdin = os.Stdin cloneCmd.Stdout = os.Stdout cloneCmd.Stderr = os.Stderr - return run.PrepareCmd(cloneCmd).Run() + err := run.PrepareCmd(cloneCmd).Run() + if err != nil { + return err + } + + if parentRepo != nil { + // TODO: support SSH remote URLs + upstreamURL := fmt.Sprintf("https://github.com/%s.git", ghrepo.FullName(parentRepo)) + cloneDir := path.Base(strings.TrimSuffix(cloneURL, ".git")) + + cloneCmd := git.GitCommand("-C", cloneDir, "remote", "add", "upstream", upstreamURL) + cloneCmd.Stdout = os.Stdout + cloneCmd.Stderr = os.Stderr + err := run.PrepareCmd(cloneCmd).Run() + if err != nil { + return err + } + } + + return nil } func repoCreate(cmd *cobra.Command, args []string) error { diff --git a/command/repo_test.go b/command/repo_test.go index 1b9aa2642..10820cd45 100644 --- a/command/repo_test.go +++ b/command/repo_test.go @@ -363,12 +363,17 @@ func TestRepoClone(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() + http := initFakeHTTP() + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "parent": null + } } } + `)) + + cs, restore := test.InitCmdStubber() + defer restore() + + cs.Stub("") // git clone output, err := RunCommand(repoCloneCmd, tt.args) if err != nil { @@ -377,15 +382,38 @@ func TestRepoClone(t *testing.T) { eq(t, output.String(), "") eq(t, output.Stderr(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - eq(t, strings.Join(seenCmd.Args, " "), tt.want) + eq(t, cs.Count, 1) + eq(t, strings.Join(cs.Calls[0].Args, " "), tt.want) }) } } +func TestRepoClone_hasParent(t *testing.T) { + http := initFakeHTTP() + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "parent": { + "owner": {"login": "hubot"}, + "name": "ORIG" + } + } } } + `)) + + cs, restore := test.InitCmdStubber() + defer restore() + + cs.Stub("") // git clone + cs.Stub("") // git remote add + + _, err := RunCommand(repoCloneCmd, "repo clone OWNER/REPO") + if err != nil { + t.Fatalf("error running command `repo clone`: %v", err) + } + + eq(t, cs.Count, 2) + eq(t, strings.Join(cs.Calls[1].Args, " "), "git -C REPO remote add upstream https://github.com/hubot/ORIG.git") +} + func TestRepoCreate(t *testing.T) { ctx := context.NewBlank() ctx.SetBranch("master") diff --git a/go.mod b/go.mod index ee45df4ed..aab4c58d4 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,8 @@ require ( github.com/mattn/go-runewidth v0.0.8 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b github.com/mitchellh/go-homedir v1.1.0 + github.com/shurcooL/githubv4 v0.0.0-20191127044304-8f68eb5628d0 + github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f // indirect github.com/spf13/cobra v0.0.6 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.4.0 // indirect diff --git a/go.sum b/go.sum index b94236621..a41c1d54b 100644 --- a/go.sum +++ b/go.sum @@ -145,6 +145,10 @@ github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shurcooL/githubv4 v0.0.0-20191127044304-8f68eb5628d0 h1:T9uus1QvcPgeLShS30YOnnzk3r9Vvygp45muhlrufgY= +github.com/shurcooL/githubv4 v0.0.0-20191127044304-8f68eb5628d0/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo= +github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk= +github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=