isolate repo fork command and tweak usage

This commit is another isolation refactor, this time for repo fork.
However, I got fed up with the --remote="true|false|prompt" style of
flags and took this opportunity to switch to a set of bool flags:

--remote and --clone
--no-remote and --no-clone

the string args were really non standard and confusing; with only two
bools it was impossible to tell when to prompt.
This commit is contained in:
vilmibm 2020-07-24 14:57:03 -05:00
parent 9035418ca6
commit f3eb092d7e
10 changed files with 829 additions and 662 deletions

View file

@ -1,29 +1,11 @@
package command
import (
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
func init() {
repoCmd.AddCommand(repoForkCmd)
repoForkCmd.Flags().String("clone", "prompt", "Clone fork: {true|false|prompt}")
repoForkCmd.Flags().String("remote", "prompt", "Add remote for fork: {true|false|prompt}")
repoForkCmd.Flags().Lookup("clone").NoOptDefVal = "true"
repoForkCmd.Flags().Lookup("remote").NoOptDefVal = "true"
repoCmd.AddCommand(repoCreditsCmd)
repoCreditsCmd.Flags().BoolP("static", "s", false, "Print a static version of the credits")
}
@ -45,243 +27,27 @@ A repository can be supplied as an argument in any of the following formats:
- by URL, e.g. "https://github.com/OWNER/REPO"`},
}
var repoForkCmd = &cobra.Command{
Use: "fork [<repository>]",
Short: "Create a fork of a repository",
Long: `Create a fork of a repository.
With no argument, creates a fork of the current repository. Otherwise, forks the specified repository.`,
RunE: repoFork,
}
var repoCreditsCmd = &cobra.Command{
Use: "credits [<repository>]",
Short: "View credits for a repository",
Example: heredoc.Doc(`
# view credits for the current repository
$ gh repo credits
# view credits for a specific repository
$ gh repo credits cool/repo
# view credits for the current repository
$ gh repo credits
# print a non-animated thank you
$ gh repo credits -s
# pipe to just print the contributors, one per line
$ gh repo credits | cat
`),
# view credits for a specific repository
$ gh repo credits cool/repo
# print a non-animated thank you
$ gh repo credits -s
# pipe to just print the contributors, one per line
$ gh repo credits | cat
`),
Args: cobra.MaximumNArgs(1),
RunE: repoCredits,
Hidden: true,
}
var Since = func(t time.Time) time.Duration {
return time.Since(t)
}
func repoFork(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
clonePref, err := cmd.Flags().GetString("clone")
if err != nil {
return err
}
remotePref, err := cmd.Flags().GetString("remote")
if err != nil {
return err
}
apiClient, err := apiClientForContext(ctx)
if err != nil {
return fmt.Errorf("unable to create client: %w", err)
}
var repoToFork ghrepo.Interface
inParent := false // whether or not we're forking the repo we're currently "in"
if len(args) == 0 {
baseRepo, err := determineBaseRepo(apiClient, cmd, ctx)
if err != nil {
return fmt.Errorf("unable to determine base repository: %w", err)
}
inParent = true
repoToFork = baseRepo
} else {
repoArg := args[0]
if utils.IsURL(repoArg) {
parsedURL, err := url.Parse(repoArg)
if err != nil {
return fmt.Errorf("did not understand argument: %w", err)
}
repoToFork, err = ghrepo.FromURL(parsedURL)
if err != nil {
return fmt.Errorf("did not understand argument: %w", err)
}
} else if strings.HasPrefix(repoArg, "git@") {
parsedURL, err := git.ParseURL(repoArg)
if err != nil {
return fmt.Errorf("did not understand argument: %w", err)
}
repoToFork, err = ghrepo.FromURL(parsedURL)
if err != nil {
return fmt.Errorf("did not understand argument: %w", err)
}
} else {
repoToFork, err = ghrepo.FromFullName(repoArg)
if err != nil {
return fmt.Errorf("argument error: %w", err)
}
}
}
if !connectedToTerminal(cmd) {
if (inParent && remotePref == "prompt") || (!inParent && clonePref == "prompt") {
return errors.New("--remote or --clone must be explicitly set when not attached to tty")
}
}
greenCheck := utils.Green("✓")
stderr := colorableErr(cmd)
s := utils.Spinner(stderr)
stopSpinner := func() {}
if connectedToTerminal(cmd) {
loading := utils.Gray("Forking ") + utils.Bold(utils.Gray(ghrepo.FullName(repoToFork))) + utils.Gray("...")
s.Suffix = " " + loading
s.FinalMSG = utils.Gray(fmt.Sprintf("- %s\n", loading))
utils.StartSpinner(s)
stopSpinner = func() {
utils.StopSpinner(s)
}
}
forkedRepo, err := api.ForkRepo(apiClient, repoToFork)
if err != nil {
stopSpinner()
return fmt.Errorf("failed to fork: %w", err)
}
stopSpinner()
// This is weird. There is not an efficient way to determine via the GitHub API whether or not a
// given user has forked a given repo. We noticed, also, that the create fork API endpoint just
// returns the fork repo data even if it already exists -- with no change in status code or
// anything. We thus check the created time to see if the repo is brand new or not; if it's not,
// we assume the fork already existed and report an error.
createdAgo := Since(forkedRepo.CreatedAt)
if createdAgo > time.Minute {
if connectedToTerminal(cmd) {
fmt.Fprintf(stderr, "%s %s %s\n",
utils.Yellow("!"),
utils.Bold(ghrepo.FullName(forkedRepo)),
"already exists")
} else {
fmt.Fprintf(stderr, "%s already exists", ghrepo.FullName(forkedRepo))
return nil
}
} else {
if connectedToTerminal(cmd) {
fmt.Fprintf(stderr, "%s Created fork %s\n", greenCheck, utils.Bold(ghrepo.FullName(forkedRepo)))
}
}
if (inParent && remotePref == "false") || (!inParent && clonePref == "false") {
return nil
}
if inParent {
remotes, err := ctx.Remotes()
if err != nil {
return err
}
if remote, err := remotes.FindByRepo(forkedRepo.RepoOwner(), forkedRepo.RepoName()); err == nil {
if connectedToTerminal(cmd) {
fmt.Fprintf(stderr, "%s Using existing remote %s\n", greenCheck, utils.Bold(remote.Name))
}
return nil
}
remoteDesired := remotePref == "true"
if remotePref == "prompt" {
err = prompt.Confirm("Would you like to add a remote for the fork?", &remoteDesired)
if err != nil {
return fmt.Errorf("failed to prompt: %w", err)
}
}
if remoteDesired {
remoteName := "origin"
remotes, err := ctx.Remotes()
if err != nil {
return err
}
if _, err := remotes.FindByName(remoteName); err == nil {
renameTarget := "upstream"
renameCmd := git.GitCommand("remote", "rename", remoteName, renameTarget)
err = run.PrepareCmd(renameCmd).Run()
if err != nil {
return err
}
if connectedToTerminal(cmd) {
fmt.Fprintf(stderr, "%s Renamed %s remote to %s\n", greenCheck, utils.Bold(remoteName), utils.Bold(renameTarget))
}
}
forkedRepoCloneURL := formatRemoteURL(cmd, forkedRepo)
_, err = git.AddRemote(remoteName, forkedRepoCloneURL)
if err != nil {
return fmt.Errorf("failed to add remote: %w", err)
}
if connectedToTerminal(cmd) {
fmt.Fprintf(stderr, "%s Added remote %s\n", greenCheck, utils.Bold(remoteName))
}
}
} else {
cloneDesired := clonePref == "true"
if clonePref == "prompt" {
err = prompt.Confirm("Would you like to clone the fork?", &cloneDesired)
if err != nil {
return fmt.Errorf("failed to prompt: %w", err)
}
}
if cloneDesired {
forkedRepoCloneURL := formatRemoteURL(cmd, forkedRepo)
cloneDir, err := git.RunClone(forkedRepoCloneURL, []string{})
if err != nil {
return fmt.Errorf("failed to clone fork: %w", err)
}
// TODO This is overly wordy and I'd like to streamline this.
cfg, err := ctx.Config()
if err != nil {
return err
}
protocol, err := cfg.Get("", "git_protocol")
if err != nil {
return err
}
upstreamURL := ghrepo.FormatRemoteURL(repoToFork, protocol)
err = git.AddUpstreamRemote(upstreamURL, cloneDir)
if err != nil {
return err
}
if connectedToTerminal(cmd) {
fmt.Fprintf(stderr, "%s Cloned fork\n", greenCheck)
}
}
}
return nil
}
func repoCredits(cmd *cobra.Command, args []string) error {
return credits(cmd, args)
}

View file

@ -1,415 +0,0 @@
package command
import (
"os/exec"
"regexp"
"strings"
"testing"
"time"
"github.com/briandowns/spinner"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/test"
"github.com/cli/cli/utils"
"github.com/stretchr/testify/assert"
)
func stubSpinner() {
// not bothering with teardown since we never want spinners when doing tests
utils.StartSpinner = func(_ *spinner.Spinner) {
}
utils.StopSpinner = func(_ *spinner.Spinner) {
}
}
func TestRepoFork_nontty_insufficient_flags(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer stubTerminal(false)()
_, err := RunCommand("repo fork")
if err == nil {
t.Fatal("expected error")
}
assert.Equal(t, "--remote or --clone must be explicitly set when not attached to tty", err.Error())
}
func TestRepoFork_in_parent_nontty(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer http.StubWithFixture(200, "forkResult.json")()
defer stubTerminal(false)()
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git remote rename
cs.Stub("") // git remote add
output, err := RunCommand("repo fork --remote")
if err != nil {
t.Fatalf("error running command `repo fork`: %v", err)
}
eq(t, strings.Join(cs.Calls[0].Args, " "), "git remote rename origin upstream")
eq(t, strings.Join(cs.Calls[1].Args, " "), "git remote add -f origin https://github.com/someone/REPO.git")
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}
func TestRepoFork_outside_parent_nontty(t *testing.T) {
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
defer http.StubWithFixture(200, "forkResult.json")()
defer stubTerminal(false)()
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
cs.Stub("") // git remote add
output, err := RunCommand("repo fork --clone OWNER/REPO")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
eq(t, output.String(), "")
eq(t, strings.Join(cs.Calls[0].Args, " "), "git clone https://github.com/someone/REPO.git")
eq(t, strings.Join(cs.Calls[1].Args, " "), "git -C REPO remote add -f upstream https://github.com/OWNER/REPO.git")
assert.Equal(t, output.Stderr(), "")
}
func TestRepoFork_already_forked(t *testing.T) {
stubSpinner()
initContext = func() context.Context {
ctx := context.NewBlank()
ctx.SetBaseRepo("OWNER/REPO")
ctx.SetBranch("master")
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
})
return ctx
}
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer http.StubWithFixture(200, "forkResult.json")()
defer stubTerminal(true)()
output, err := RunCommand("repo fork --remote=false")
if err != nil {
t.Errorf("got unexpected error: %v", err)
}
r := regexp.MustCompile(`someone/REPO.*already exists`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output.Stderr())
return
}
}
func TestRepoFork_reuseRemote(t *testing.T) {
stubSpinner()
initContext = func() context.Context {
ctx := context.NewBlank()
ctx.SetBaseRepo("OWNER/REPO")
ctx.SetBranch("master")
ctx.SetRemotes(map[string]string{
"upstream": "OWNER/REPO",
"origin": "someone/REPO",
})
return ctx
}
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer http.StubWithFixture(200, "forkResult.json")()
defer stubTerminal(true)()
output, err := RunCommand("repo fork")
if err != nil {
t.Errorf("got unexpected error: %v", err)
}
r := regexp.MustCompile(`Using existing remote.*origin`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match: %q", output.Stderr())
return
}
}
func stubSince(d time.Duration) func() {
originalSince := Since
Since = func(t time.Time) time.Duration {
return d
}
return func() {
Since = originalSince
}
}
func TestRepoFork_in_parent(t *testing.T) {
stubSpinner()
initBlankContext("", "OWNER/REPO", "master")
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer http.StubWithFixture(200, "forkResult.json")()
defer stubTerminal(true)()
output, err := RunCommand("repo fork --remote=false")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
eq(t, output.String(), "")
r := regexp.MustCompile(`Created fork.*someone/REPO`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
}
func TestRepoFork_outside(t *testing.T) {
stubSpinner()
tests := []struct {
name string
args string
}{
{
name: "url arg",
args: "repo fork --clone=false http://github.com/OWNER/REPO.git",
},
{
name: "full name arg",
args: "repo fork --clone=false OWNER/REPO",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
defer http.StubWithFixture(200, "forkResult.json")()
defer stubTerminal(true)()
output, err := RunCommand(tt.args)
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
eq(t, output.String(), "")
r := regexp.MustCompile(`Created fork.*someone/REPO`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
})
}
}
func TestRepoFork_in_parent_yes(t *testing.T) {
stubSpinner()
initBlankContext("", "OWNER/REPO", "master")
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer http.StubWithFixture(200, "forkResult.json")()
defer stubTerminal(true)()
var seenCmds []*exec.Cmd
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmds = append(seenCmds, cmd)
return &test.OutputStub{}
})()
output, err := RunCommand("repo fork --remote")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
expectedCmds := []string{
"git remote rename origin upstream",
"git remote add -f origin https://github.com/someone/REPO.git",
}
for x, cmd := range seenCmds {
eq(t, strings.Join(cmd.Args, " "), expectedCmds[x])
}
eq(t, output.String(), "")
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
"Added remote.*origin")
}
func TestRepoFork_outside_yes(t *testing.T) {
stubSpinner()
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
defer http.StubWithFixture(200, "forkResult.json")()
defer stubTerminal(true)()
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
cs.Stub("") // git remote add
output, err := RunCommand("repo fork --clone OWNER/REPO")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
eq(t, output.String(), "")
eq(t, strings.Join(cs.Calls[0].Args, " "), "git clone https://github.com/someone/REPO.git")
eq(t, strings.Join(cs.Calls[1].Args, " "), "git -C REPO remote add -f upstream https://github.com/OWNER/REPO.git")
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
"Cloned fork")
}
func TestRepoFork_outside_survey_yes(t *testing.T) {
stubSpinner()
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
defer http.StubWithFixture(200, "forkResult.json")()
defer stubTerminal(true)()
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
cs.Stub("") // git remote add
defer prompt.StubConfirm(true)()
output, err := RunCommand("repo fork OWNER/REPO")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
eq(t, output.String(), "")
eq(t, strings.Join(cs.Calls[0].Args, " "), "git clone https://github.com/someone/REPO.git")
eq(t, strings.Join(cs.Calls[1].Args, " "), "git -C REPO remote add -f upstream https://github.com/OWNER/REPO.git")
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
"Cloned fork")
}
func TestRepoFork_outside_survey_no(t *testing.T) {
stubSpinner()
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
defer http.StubWithFixture(200, "forkResult.json")()
defer stubTerminal(true)()
cmdRun := false
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
cmdRun = true
return &test.OutputStub{}
})()
defer prompt.StubConfirm(false)()
output, err := RunCommand("repo fork OWNER/REPO")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
eq(t, output.String(), "")
eq(t, cmdRun, false)
r := regexp.MustCompile(`Created fork.*someone/REPO`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
}
func TestRepoFork_in_parent_survey_yes(t *testing.T) {
stubSpinner()
initBlankContext("", "OWNER/REPO", "master")
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer http.StubWithFixture(200, "forkResult.json")()
defer stubTerminal(true)()
var seenCmds []*exec.Cmd
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmds = append(seenCmds, cmd)
return &test.OutputStub{}
})()
defer prompt.StubConfirm(true)()
output, err := RunCommand("repo fork")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
expectedCmds := []string{
"git remote rename origin upstream",
"git remote add -f origin https://github.com/someone/REPO.git",
}
for x, cmd := range seenCmds {
eq(t, strings.Join(cmd.Args, " "), expectedCmds[x])
}
eq(t, output.String(), "")
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
"Renamed.*origin.*remote to.*upstream",
"Added remote.*origin")
}
func TestRepoFork_in_parent_survey_no(t *testing.T) {
stubSpinner()
initBlankContext("", "OWNER/REPO", "master")
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer http.StubWithFixture(200, "forkResult.json")()
defer stubTerminal(true)()
cmdRun := false
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
cmdRun = true
return &test.OutputStub{}
})()
defer prompt.StubConfirm(false)()
output, err := RunCommand("repo fork")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
eq(t, output.String(), "")
eq(t, cmdRun, false)
r := regexp.MustCompile(`Created fork.*someone/REPO`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
}

View file

@ -23,6 +23,7 @@ import (
gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create"
repoCloneCmd "github.com/cli/cli/pkg/cmd/repo/clone"
repoCreateCmd "github.com/cli/cli/pkg/cmd/repo/create"
repoForkCmd "github.com/cli/cli/pkg/cmd/repo/fork"
repoViewCmd "github.com/cli/cli/pkg/cmd/repo/view"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -97,6 +98,10 @@ func init() {
ctx := context.New()
return ctx.BaseRepo()
},
Remotes: func() (context.Remotes, error) {
ctx := context.New()
return ctx.Remotes()
},
Config: func() (config.Config, error) {
cfg, err := config.ParseDefaultConfig()
if errors.Is(err, os.ErrNotExist) {
@ -148,6 +153,7 @@ func init() {
RootCmd.AddCommand(repoCmd)
repoCmd.AddCommand(repoViewCmd.NewCmdView(&repoResolvingCmdFactory, nil))
repoCmd.AddCommand(repoForkCmd.NewCmdFork(&repoResolvingCmdFactory, nil))
repoCmd.AddCommand(repoCloneCmd.NewCmdClone(cmdFactory, nil))
repoCmd.AddCommand(repoCreateCmd.NewCmdCreate(cmdFactory, nil))
}

View file

@ -45,7 +45,7 @@ func TestChangelogURL(t *testing.T) {
func TestRemoteURLFormatting_no_config(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
result := formatRemoteURL(repoForkCmd, ghrepo.New("OWNER", "REPO"))
result := formatRemoteURL(prCheckoutCmd, ghrepo.New("OWNER", "REPO"))
eq(t, result, "https://github.com/OWNER/REPO.git")
}
@ -58,6 +58,6 @@ hosts:
git_protocol: ssh
`
initBlankContext(cfg, "OWNER/REPO", "master")
result := formatRemoteURL(repoForkCmd, ghrepo.New("OWNER", "REPO"))
result := formatRemoteURL(prCheckoutCmd, ghrepo.New("OWNER", "REPO"))
eq(t, result, "git@github.com:OWNER/REPO.git")
}

292
pkg/cmd/repo/fork/fork.go Normal file
View file

@ -0,0 +1,292 @@
package fork
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type ForkOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Remotes func() (context.Remotes, error)
Since func(t time.Time) time.Duration
Repository string
Clone bool
Remote bool
PromptClone bool
PromptRemote bool
}
// TODO decide if this should be injected via a factory.
var Since = func(t time.Time) time.Duration {
return time.Since(t)
}
func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Command {
opts := &ForkOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
BaseRepo: f.BaseRepo,
Remotes: f.Remotes,
}
cmd := &cobra.Command{
Use: "fork [<repository>]",
Args: cobra.MaximumNArgs(1),
Short: "Create a fork of a repository",
Long: `Create a fork of a repository.
With no argument, creates a fork of the current repository. Otherwise, forks the specified repository.`,
RunE: func(cmd *cobra.Command, args []string) error {
promptOk := opts.IO.IsStdoutTTY() && opts.IO.IsStdinTTY()
if len(args) > 0 {
opts.Repository = args[0]
}
if cmd.Flags().Changed("clone") && cmd.Flags().Changed("no-clone") {
return errors.New("--clone and --no-clone are mutually excusive")
}
if cmd.Flags().Changed("remote") && cmd.Flags().Changed("no-remote") {
return errors.New("--remote and --no-remote are mutually excusive")
}
if promptOk && (!cmd.Flags().Changed("clone") && !cmd.Flags().Changed("no-clone")) {
opts.PromptClone = true
}
if promptOk && (!cmd.Flags().Changed("remote") && !cmd.Flags().Changed("no-remote")) {
opts.PromptRemote = true
}
if cmd.Flags().Changed("no-clone") {
opts.Clone = false
}
if cmd.Flags().Changed("no-remote") {
opts.Remote = false
}
if runF != nil {
return runF(opts)
}
return forkRun(opts)
},
}
cmd.Flags().BoolVar(&opts.Clone, "clone", false, "Clone the fork")
cmd.Flags().BoolVar(&opts.Remote, "remote", false, "Add remote for fork")
cmd.Flags().Bool("no-clone", false, "Do not clone the fork")
cmd.Flags().Bool("no-remote", false, "Do not add remote for fork")
return cmd
}
func forkRun(opts *ForkOptions) error {
var repoToFork ghrepo.Interface
var err error
inParent := false // whether or not we're forking the repo we're currently "in"
if opts.Repository == "" {
baseRepo, err := opts.BaseRepo()
if err != nil {
return fmt.Errorf("unable to determine base repository: %w", err)
}
inParent = true
repoToFork = baseRepo
} else {
repoArg := opts.Repository
if utils.IsURL(repoArg) {
parsedURL, err := url.Parse(repoArg)
if err != nil {
return fmt.Errorf("did not understand argument: %w", err)
}
repoToFork, err = ghrepo.FromURL(parsedURL)
if err != nil {
return fmt.Errorf("did not understand argument: %w", err)
}
} else if strings.HasPrefix(repoArg, "git@") {
parsedURL, err := git.ParseURL(repoArg)
if err != nil {
return fmt.Errorf("did not understand argument: %w", err)
}
repoToFork, err = ghrepo.FromURL(parsedURL)
if err != nil {
return fmt.Errorf("did not understand argument: %w", err)
}
} else {
repoToFork, err = ghrepo.FromFullName(repoArg)
if err != nil {
return fmt.Errorf("argument error: %w", err)
}
}
}
connectedToTerminal := opts.IO.IsStdoutTTY() && opts.IO.IsStderrTTY() && opts.IO.IsStdinTTY()
greenCheck := utils.Green("✓")
stderr := opts.IO.ErrOut
s := utils.Spinner(stderr)
stopSpinner := func() {}
if connectedToTerminal {
loading := utils.Gray("Forking ") + utils.Bold(utils.Gray(ghrepo.FullName(repoToFork))) + utils.Gray("...")
s.Suffix = " " + loading
s.FinalMSG = utils.Gray(fmt.Sprintf("- %s\n", loading))
utils.StartSpinner(s)
stopSpinner = func() {
utils.StopSpinner(s)
}
}
httpClient, err := opts.HttpClient()
if err != nil {
return fmt.Errorf("unable to create client: %w", err)
}
apiClient := api.NewClientFromHTTP(httpClient)
forkedRepo, err := api.ForkRepo(apiClient, repoToFork)
if err != nil {
stopSpinner()
return fmt.Errorf("failed to fork: %w", err)
}
stopSpinner()
// This is weird. There is not an efficient way to determine via the GitHub API whether or not a
// given user has forked a given repo. We noticed, also, that the create fork API endpoint just
// returns the fork repo data even if it already exists -- with no change in status code or
// anything. We thus check the created time to see if the repo is brand new or not; if it's not,
// we assume the fork already existed and report an error.
createdAgo := Since(forkedRepo.CreatedAt)
if createdAgo > time.Minute {
if connectedToTerminal {
fmt.Fprintf(stderr, "%s %s %s\n",
utils.Yellow("!"),
utils.Bold(ghrepo.FullName(forkedRepo)),
"already exists")
} else {
fmt.Fprintf(stderr, "%s already exists", ghrepo.FullName(forkedRepo))
return nil
}
} else {
if connectedToTerminal {
fmt.Fprintf(stderr, "%s Created fork %s\n", greenCheck, utils.Bold(ghrepo.FullName(forkedRepo)))
}
}
if (inParent && (!opts.Remote && !opts.PromptRemote)) || (!inParent && (!opts.Clone && !opts.PromptClone)) {
return nil
}
// TODO This is overly wordy and I'd like to streamline this.
cfg, err := opts.Config()
if err != nil {
return err
}
protocol, err := cfg.Get("", "git_protocol")
if err != nil {
return err
}
if inParent {
remotes, err := opts.Remotes()
if err != nil {
return err
}
if remote, err := remotes.FindByRepo(forkedRepo.RepoOwner(), forkedRepo.RepoName()); err == nil {
if connectedToTerminal {
fmt.Fprintf(stderr, "%s Using existing remote %s\n", greenCheck, utils.Bold(remote.Name))
}
return nil
}
remoteDesired := opts.Remote
if opts.PromptRemote {
err = prompt.Confirm("Would you like to add a remote for the fork?", &remoteDesired)
if err != nil {
return fmt.Errorf("failed to prompt: %w", err)
}
}
if remoteDesired {
remoteName := "origin"
remotes, err := opts.Remotes()
if err != nil {
return err
}
if _, err := remotes.FindByName(remoteName); err == nil {
renameTarget := "upstream"
renameCmd := git.GitCommand("remote", "rename", remoteName, renameTarget)
err = run.PrepareCmd(renameCmd).Run()
if err != nil {
return err
}
if connectedToTerminal {
fmt.Fprintf(stderr, "%s Renamed %s remote to %s\n", greenCheck, utils.Bold(remoteName), utils.Bold(renameTarget))
}
}
forkedRepoCloneURL := ghrepo.FormatRemoteURL(forkedRepo, protocol)
_, err = git.AddRemote(remoteName, forkedRepoCloneURL)
if err != nil {
return fmt.Errorf("failed to add remote: %w", err)
}
if connectedToTerminal {
fmt.Fprintf(stderr, "%s Added remote %s\n", greenCheck, utils.Bold(remoteName))
}
}
} else {
cloneDesired := opts.Clone
if opts.PromptClone {
err = prompt.Confirm("Would you like to clone the fork?", &cloneDesired)
if err != nil {
return fmt.Errorf("failed to prompt: %w", err)
}
}
if cloneDesired {
forkedRepoURL := ghrepo.FormatRemoteURL(forkedRepo, protocol)
cloneDir, err := git.RunClone(forkedRepoURL, []string{})
if err != nil {
return fmt.Errorf("failed to clone fork: %w", err)
}
upstreamURL := ghrepo.FormatRemoteURL(repoToFork, protocol)
err = git.AddUpstreamRemote(upstreamURL, cloneDir)
if err != nil {
return err
}
if connectedToTerminal {
fmt.Fprintf(stderr, "%s Cloned fork\n", greenCheck)
}
}
}
return nil
}

View file

@ -0,0 +1,484 @@
package fork
import (
"net/http"
"os/exec"
"regexp"
"strings"
"testing"
"time"
"github.com/briandowns/spinner"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/test"
"github.com/cli/cli/utils"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func runCommand(httpClient *http.Client, remotes []*context.Remote, isTTY bool, cli string) (*test.CmdOut, error) {
io, stdin, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
io.SetStdinTTY(isTTY)
io.SetStderrTTY(isTTY)
fac := &cmdutil.Factory{
IOStreams: io,
HttpClient: func() (*http.Client, error) {
return httpClient, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
Remotes: func() (context.Remotes, error) {
if remotes == nil {
return []*context.Remote{
{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.New("OWNER", "REPO"),
},
}, nil
}
return remotes, nil
},
}
cmd := NewCmdFork(fac, nil)
argv, err := shlex.Split(cli)
cmd.SetArgs(argv)
cmd.SetIn(stdin)
cmd.SetOut(stdout)
cmd.SetErr(stderr)
if err != nil {
panic(err)
}
_, err = cmd.ExecuteC()
if err != nil {
return nil, err
}
return &test.CmdOut{
OutBuf: stdout,
ErrBuf: stderr}, nil
}
func TestRepoFork_nontty(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
output, err := runCommand(httpClient, nil, false, "")
if err != nil {
t.Fatalf("error running command `repo fork`: %v", err)
}
assert.Equal(t, 0, len(cs.Calls))
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
reg.Verify(t)
}
func TestRepoFork_in_parent_nontty(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git remote rename
cs.Stub("") // git remote add
output, err := runCommand(httpClient, nil, false, "--remote")
if err != nil {
t.Fatalf("error running command `repo fork`: %v", err)
}
assert.Equal(t, 2, len(cs.Calls))
assert.Equal(t, "git remote rename origin upstream", strings.Join(cs.Calls[0].Args, " "))
assert.Equal(t, "git remote add -f origin https://github.com/someone/REPO.git", strings.Join(cs.Calls[1].Args, " "))
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
reg.Verify(t)
}
func TestRepoFork_outside_parent_nontty(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
cs.Stub("") // git remote add
output, err := runCommand(httpClient, nil, false, "--clone OWNER/REPO")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
assert.Equal(t, "", output.String())
assert.Equal(t, "git clone https://github.com/someone/REPO.git", strings.Join(cs.Calls[0].Args, " "))
assert.Equal(t, "git -C REPO remote add -f upstream https://github.com/OWNER/REPO.git", strings.Join(cs.Calls[1].Args, " "))
assert.Equal(t, output.Stderr(), "")
reg.Verify(t)
}
func TestRepoFork_already_forked(t *testing.T) {
stubSpinner()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
output, err := runCommand(httpClient, nil, true, "--no-remote")
if err != nil {
t.Errorf("got unexpected error: %v", err)
}
assert.Equal(t, 0, len(cs.Calls))
r := regexp.MustCompile(`someone/REPO.*already exists`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output.Stderr())
return
}
reg.Verify(t)
}
func TestRepoFork_reuseRemote(t *testing.T) {
stubSpinner()
remotes := []*context.Remote{
{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.New("someone", "REPO"),
},
{
Remote: &git.Remote{Name: "upstream"},
Repo: ghrepo.New("OWNER", "REPO"),
},
}
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
output, err := runCommand(httpClient, remotes, true, "--remote")
if err != nil {
t.Errorf("got unexpected error: %v", err)
}
r := regexp.MustCompile(`Using existing remote.*origin`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match: %q", output.Stderr())
return
}
reg.Verify(t)
}
func TestRepoFork_in_parent(t *testing.T) {
stubSpinner()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
defer stubSince(2 * time.Second)()
output, err := runCommand(httpClient, nil, true, "--no-remote")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
assert.Equal(t, 0, len(cs.Calls))
assert.Equal(t, "", output.String())
r := regexp.MustCompile(`Created fork.*someone/REPO`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
reg.Verify(t)
}
func TestRepoFork_outside(t *testing.T) {
stubSpinner()
tests := []struct {
name string
args string
}{
{
name: "url arg",
args: "--no-clone http://github.com/OWNER/REPO.git",
},
{
name: "full name arg",
args: "--no-clone OWNER/REPO",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
output, err := runCommand(httpClient, nil, true, tt.args)
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
assert.Equal(t, "", output.String())
r := regexp.MustCompile(`Created fork.*someone/REPO`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
reg.Verify(t)
})
}
}
func TestRepoFork_in_parent_yes(t *testing.T) {
stubSpinner()
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
var seenCmds []*exec.Cmd
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmds = append(seenCmds, cmd)
return &test.OutputStub{}
})()
output, err := runCommand(httpClient, nil, true, "--remote")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
expectedCmds := []string{
"git remote rename origin upstream",
"git remote add -f origin https://github.com/someone/REPO.git",
}
for x, cmd := range seenCmds {
assert.Equal(t, expectedCmds[x], strings.Join(cmd.Args, " "))
}
assert.Equal(t, "", output.String())
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
"Added remote.*origin")
reg.Verify(t)
}
func TestRepoFork_outside_yes(t *testing.T) {
stubSpinner()
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
cs.Stub("") // git remote add
output, err := runCommand(httpClient, nil, true, "--clone OWNER/REPO")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
assert.Equal(t, "", output.String())
assert.Equal(t, "git clone https://github.com/someone/REPO.git", strings.Join(cs.Calls[0].Args, " "))
assert.Equal(t, "git -C REPO remote add -f upstream https://github.com/OWNER/REPO.git", strings.Join(cs.Calls[1].Args, " "))
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
"Cloned fork")
reg.Verify(t)
}
func TestRepoFork_outside_survey_yes(t *testing.T) {
stubSpinner()
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
cs.Stub("") // git remote add
defer prompt.StubConfirm(true)()
output, err := runCommand(httpClient, nil, true, "OWNER/REPO")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
assert.Equal(t, "", output.String())
assert.Equal(t, "git clone https://github.com/someone/REPO.git", strings.Join(cs.Calls[0].Args, " "))
assert.Equal(t, "git -C REPO remote add -f upstream https://github.com/OWNER/REPO.git", strings.Join(cs.Calls[1].Args, " "))
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
"Cloned fork")
reg.Verify(t)
}
func TestRepoFork_outside_survey_no(t *testing.T) {
stubSpinner()
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cmdRun := false
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
cmdRun = true
return &test.OutputStub{}
})()
defer prompt.StubConfirm(false)()
output, err := runCommand(httpClient, nil, true, "OWNER/REPO")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
assert.Equal(t, "", output.String())
assert.Equal(t, false, cmdRun)
r := regexp.MustCompile(`Created fork.*someone/REPO`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
reg.Verify(t)
}
func TestRepoFork_in_parent_survey_yes(t *testing.T) {
stubSpinner()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
defer stubSince(2 * time.Second)()
var seenCmds []*exec.Cmd
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmds = append(seenCmds, cmd)
return &test.OutputStub{}
})()
defer prompt.StubConfirm(true)()
output, err := runCommand(httpClient, nil, true, "")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
expectedCmds := []string{
"git remote rename origin upstream",
"git remote add -f origin https://github.com/someone/REPO.git",
}
for x, cmd := range seenCmds {
assert.Equal(t, expectedCmds[x], strings.Join(cmd.Args, " "))
}
assert.Equal(t, "", output.String())
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
"Renamed.*origin.*remote to.*upstream",
"Added remote.*origin")
reg.Verify(t)
}
func TestRepoFork_in_parent_survey_no(t *testing.T) {
stubSpinner()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
defer stubSince(2 * time.Second)()
cmdRun := false
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
cmdRun = true
return &test.OutputStub{}
})()
defer prompt.StubConfirm(false)()
output, err := runCommand(httpClient, nil, true, "")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
assert.Equal(t, "", output.String())
assert.Equal(t, false, cmdRun)
r := regexp.MustCompile(`Created fork.*someone/REPO`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
reg.Verify(t)
}
func stubSpinner() {
// not bothering with teardown since we never want spinners when doing tests
utils.StartSpinner = func(_ *spinner.Spinner) {
}
utils.StopSpinner = func(_ *spinner.Spinner) {
}
}
func stubSince(d time.Duration) func() {
originalSince := Since
Since = func(t time.Time) time.Duration {
return d
}
return func() {
Since = originalSince
}
}

View file

@ -3,6 +3,7 @@ package cmdutil
import (
"net/http"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/iostreams"
@ -12,5 +13,6 @@ type Factory struct {
IOStreams *iostreams.IOStreams
HttpClient func() (*http.Client, error)
BaseRepo func() (ghrepo.Interface, error)
Remotes func() (context.Remotes, error)
Config func() (config.Config, error)
}

View file

@ -33,6 +33,21 @@ func (r *Registry) StubWithFixture(status int, fixtureFileName string) func() {
}
}
func (r *Registry) StubWithFixturePath(status int, fixturePath string) func() {
fixtureFile, err := os.Open(fixturePath)
r.Register(MatchAny, func(req *http.Request) (*http.Response, error) {
if err != nil {
return nil, err
}
return httpResponse(200, req, fixtureFile), nil
})
return func() {
if err == nil {
fixtureFile.Close()
}
}
}
func (r *Registry) StubRepoResponse(owner, repo string) {
r.StubRepoResponseWithPermission(owner, repo, "WRITE")
}

View file

@ -21,6 +21,8 @@ type IOStreams struct {
stdinIsTTY bool
stdoutTTYOverride bool
stdoutIsTTY bool
stderrTTYOverride bool
stderrIsTTY bool
}
func (s *IOStreams) ColorEnabled() bool {
@ -57,6 +59,21 @@ func (s *IOStreams) IsStdoutTTY() bool {
return false
}
func (s *IOStreams) SetStderrTTY(isTTY bool) {
s.stderrTTYOverride = true
s.stderrIsTTY = isTTY
}
func (s *IOStreams) IsStderrTTY() bool {
if s.stderrTTYOverride {
return s.stderrIsTTY
}
if stderr, ok := s.ErrOut.(*os.File); ok {
return isTerminal(stderr)
}
return false
}
func System() *IOStreams {
var out io.Writer = os.Stdout
var colorEnabled bool