github.com/cli/cli@v1.14.1-0.20210902173923-1af6a669e342/pkg/cmd/repo/fork/fork.go (about)

     1  package fork
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"net/http"
     7  	"net/url"
     8  	"strings"
     9  	"time"
    10  
    11  	"github.com/cli/cli/api"
    12  	"github.com/cli/cli/context"
    13  	"github.com/cli/cli/git"
    14  	"github.com/cli/cli/internal/config"
    15  	"github.com/cli/cli/internal/ghrepo"
    16  	"github.com/cli/cli/internal/run"
    17  	"github.com/cli/cli/pkg/cmdutil"
    18  	"github.com/cli/cli/pkg/iostreams"
    19  	"github.com/cli/cli/pkg/prompt"
    20  	"github.com/cli/cli/utils"
    21  	"github.com/spf13/cobra"
    22  	"github.com/spf13/pflag"
    23  )
    24  
    25  const defaultRemoteName = "origin"
    26  
    27  type ForkOptions struct {
    28  	HttpClient func() (*http.Client, error)
    29  	Config     func() (config.Config, error)
    30  	IO         *iostreams.IOStreams
    31  	BaseRepo   func() (ghrepo.Interface, error)
    32  	Remotes    func() (context.Remotes, error)
    33  	Since      func(time.Time) time.Duration
    34  
    35  	GitArgs      []string
    36  	Repository   string
    37  	Clone        bool
    38  	Remote       bool
    39  	PromptClone  bool
    40  	PromptRemote bool
    41  	RemoteName   string
    42  	Organization string
    43  	Rename       bool
    44  }
    45  
    46  // TODO warn about useless flags (--remote, --remote-name) when running from outside a repository
    47  // TODO output over STDOUT not STDERR
    48  // TODO remote-name has no effect on its own; error that or change behavior
    49  
    50  func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Command {
    51  	opts := &ForkOptions{
    52  		IO:         f.IOStreams,
    53  		HttpClient: f.HttpClient,
    54  		Config:     f.Config,
    55  		BaseRepo:   f.BaseRepo,
    56  		Remotes:    f.Remotes,
    57  		Since:      time.Since,
    58  	}
    59  
    60  	cmd := &cobra.Command{
    61  		Use: "fork [<repository>] [-- <gitflags>...]",
    62  		Args: func(cmd *cobra.Command, args []string) error {
    63  			if cmd.ArgsLenAtDash() == 0 && len(args[1:]) > 0 {
    64  				return cmdutil.FlagError{Err: fmt.Errorf("repository argument required when passing 'git clone' flags")}
    65  			}
    66  			return nil
    67  		},
    68  		Short: "Create a fork of a repository",
    69  		Long: `Create a fork of a repository.
    70  
    71  With no argument, creates a fork of the current repository. Otherwise, forks
    72  the specified repository.
    73  
    74  By default, the new fork is set to be your 'origin' remote and any existing
    75  origin remote is renamed to 'upstream'. To alter this behavior, you can set
    76  a name for the new fork's remote with --remote-name.
    77  
    78  Additional 'git clone' flags can be passed in by listing them after '--'.`,
    79  		RunE: func(cmd *cobra.Command, args []string) error {
    80  			promptOk := opts.IO.CanPrompt()
    81  			if len(args) > 0 {
    82  				opts.Repository = args[0]
    83  				opts.GitArgs = args[1:]
    84  			}
    85  
    86  			if cmd.Flags().Changed("org") && opts.Organization == "" {
    87  				return &cmdutil.FlagError{Err: errors.New("--org cannot be blank")}
    88  			}
    89  
    90  			if opts.RemoteName == "" {
    91  				return &cmdutil.FlagError{Err: errors.New("--remote-name cannot be blank")}
    92  			} else if !cmd.Flags().Changed("remote-name") {
    93  				opts.Rename = true // Any existing 'origin' will be renamed to upstream
    94  			}
    95  
    96  			if promptOk {
    97  				// We can prompt for these if they were not specified.
    98  				opts.PromptClone = !cmd.Flags().Changed("clone")
    99  				opts.PromptRemote = !cmd.Flags().Changed("remote")
   100  			}
   101  
   102  			if runF != nil {
   103  				return runF(opts)
   104  			}
   105  			return forkRun(opts)
   106  		},
   107  	}
   108  	cmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
   109  		if err == pflag.ErrHelp {
   110  			return err
   111  		}
   112  		return &cmdutil.FlagError{Err: fmt.Errorf("%w\nSeparate git clone flags with '--'.", err)}
   113  	})
   114  
   115  	cmd.Flags().BoolVar(&opts.Clone, "clone", false, "Clone the fork {true|false}")
   116  	cmd.Flags().BoolVar(&opts.Remote, "remote", false, "Add remote for fork {true|false}")
   117  	cmd.Flags().StringVar(&opts.RemoteName, "remote-name", defaultRemoteName, "Specify a name for a fork's new remote.")
   118  	cmd.Flags().StringVar(&opts.Organization, "org", "", "Create the fork in an organization")
   119  
   120  	return cmd
   121  }
   122  
   123  func forkRun(opts *ForkOptions) error {
   124  	var repoToFork ghrepo.Interface
   125  	var err error
   126  	inParent := false // whether or not we're forking the repo we're currently "in"
   127  	if opts.Repository == "" {
   128  		baseRepo, err := opts.BaseRepo()
   129  		if err != nil {
   130  			return fmt.Errorf("unable to determine base repository: %w", err)
   131  		}
   132  		inParent = true
   133  		repoToFork = baseRepo
   134  	} else {
   135  		repoArg := opts.Repository
   136  
   137  		if utils.IsURL(repoArg) {
   138  			parsedURL, err := url.Parse(repoArg)
   139  			if err != nil {
   140  				return fmt.Errorf("did not understand argument: %w", err)
   141  			}
   142  
   143  			repoToFork, err = ghrepo.FromURL(parsedURL)
   144  			if err != nil {
   145  				return fmt.Errorf("did not understand argument: %w", err)
   146  			}
   147  
   148  		} else if strings.HasPrefix(repoArg, "git@") {
   149  			parsedURL, err := git.ParseURL(repoArg)
   150  			if err != nil {
   151  				return fmt.Errorf("did not understand argument: %w", err)
   152  			}
   153  			repoToFork, err = ghrepo.FromURL(parsedURL)
   154  			if err != nil {
   155  				return fmt.Errorf("did not understand argument: %w", err)
   156  			}
   157  		} else {
   158  			repoToFork, err = ghrepo.FromFullName(repoArg)
   159  			if err != nil {
   160  				return fmt.Errorf("argument error: %w", err)
   161  			}
   162  		}
   163  	}
   164  
   165  	connectedToTerminal := opts.IO.IsStdoutTTY() && opts.IO.IsStderrTTY() && opts.IO.IsStdinTTY()
   166  
   167  	cs := opts.IO.ColorScheme()
   168  	stderr := opts.IO.ErrOut
   169  
   170  	httpClient, err := opts.HttpClient()
   171  	if err != nil {
   172  		return fmt.Errorf("unable to create client: %w", err)
   173  	}
   174  
   175  	apiClient := api.NewClientFromHTTP(httpClient)
   176  
   177  	opts.IO.StartProgressIndicator()
   178  	forkedRepo, err := api.ForkRepo(apiClient, repoToFork, opts.Organization)
   179  	opts.IO.StopProgressIndicator()
   180  	if err != nil {
   181  		return fmt.Errorf("failed to fork: %w", err)
   182  	}
   183  
   184  	// This is weird. There is not an efficient way to determine via the GitHub API whether or not a
   185  	// given user has forked a given repo. We noticed, also, that the create fork API endpoint just
   186  	// returns the fork repo data even if it already exists -- with no change in status code or
   187  	// anything. We thus check the created time to see if the repo is brand new or not; if it's not,
   188  	// we assume the fork already existed and report an error.
   189  	createdAgo := opts.Since(forkedRepo.CreatedAt)
   190  	if createdAgo > time.Minute {
   191  		if connectedToTerminal {
   192  			fmt.Fprintf(stderr, "%s %s %s\n",
   193  				cs.Yellow("!"),
   194  				cs.Bold(ghrepo.FullName(forkedRepo)),
   195  				"already exists")
   196  		} else {
   197  			fmt.Fprintf(stderr, "%s already exists", ghrepo.FullName(forkedRepo))
   198  		}
   199  	} else {
   200  		if connectedToTerminal {
   201  			fmt.Fprintf(stderr, "%s Created fork %s\n", cs.SuccessIconWithColor(cs.Green), cs.Bold(ghrepo.FullName(forkedRepo)))
   202  		}
   203  	}
   204  
   205  	if (inParent && (!opts.Remote && !opts.PromptRemote)) || (!inParent && (!opts.Clone && !opts.PromptClone)) {
   206  		return nil
   207  	}
   208  
   209  	cfg, err := opts.Config()
   210  	if err != nil {
   211  		return err
   212  	}
   213  	protocol, err := cfg.Get(repoToFork.RepoHost(), "git_protocol")
   214  	if err != nil {
   215  		return err
   216  	}
   217  
   218  	if inParent {
   219  		remotes, err := opts.Remotes()
   220  		if err != nil {
   221  			return err
   222  		}
   223  
   224  		if remote, err := remotes.FindByRepo(repoToFork.RepoOwner(), repoToFork.RepoName()); err == nil {
   225  
   226  			scheme := ""
   227  			if remote.FetchURL != nil {
   228  				scheme = remote.FetchURL.Scheme
   229  			}
   230  			if remote.PushURL != nil {
   231  				scheme = remote.PushURL.Scheme
   232  			}
   233  			if scheme != "" {
   234  				protocol = scheme
   235  			}
   236  		}
   237  
   238  		if remote, err := remotes.FindByRepo(forkedRepo.RepoOwner(), forkedRepo.RepoName()); err == nil {
   239  			if connectedToTerminal {
   240  				fmt.Fprintf(stderr, "%s Using existing remote %s\n", cs.SuccessIcon(), cs.Bold(remote.Name))
   241  			}
   242  			return nil
   243  		}
   244  
   245  		remoteDesired := opts.Remote
   246  		if opts.PromptRemote {
   247  			err = prompt.Confirm("Would you like to add a remote for the fork?", &remoteDesired)
   248  			if err != nil {
   249  				return fmt.Errorf("failed to prompt: %w", err)
   250  			}
   251  		}
   252  		if remoteDesired {
   253  			remoteName := opts.RemoteName
   254  			remotes, err := opts.Remotes()
   255  			if err != nil {
   256  				return err
   257  			}
   258  
   259  			if _, err := remotes.FindByName(remoteName); err == nil {
   260  				if opts.Rename {
   261  					renameTarget := "upstream"
   262  					renameCmd, err := git.GitCommand("remote", "rename", remoteName, renameTarget)
   263  					if err != nil {
   264  						return err
   265  					}
   266  					err = run.PrepareCmd(renameCmd).Run()
   267  					if err != nil {
   268  						return err
   269  					}
   270  				} else {
   271  					return fmt.Errorf("a git remote named '%s' already exists", remoteName)
   272  				}
   273  			}
   274  
   275  			forkedRepoCloneURL := ghrepo.FormatRemoteURL(forkedRepo, protocol)
   276  
   277  			_, err = git.AddRemote(remoteName, forkedRepoCloneURL)
   278  			if err != nil {
   279  				return fmt.Errorf("failed to add remote: %w", err)
   280  			}
   281  
   282  			if connectedToTerminal {
   283  				fmt.Fprintf(stderr, "%s Added remote %s\n", cs.SuccessIcon(), cs.Bold(remoteName))
   284  			}
   285  		}
   286  	} else {
   287  		cloneDesired := opts.Clone
   288  		if opts.PromptClone {
   289  			err = prompt.Confirm("Would you like to clone the fork?", &cloneDesired)
   290  			if err != nil {
   291  				return fmt.Errorf("failed to prompt: %w", err)
   292  			}
   293  		}
   294  		if cloneDesired {
   295  			forkedRepoURL := ghrepo.FormatRemoteURL(forkedRepo, protocol)
   296  			cloneDir, err := git.RunClone(forkedRepoURL, opts.GitArgs)
   297  			if err != nil {
   298  				return fmt.Errorf("failed to clone fork: %w", err)
   299  			}
   300  
   301  			upstreamURL := ghrepo.FormatRemoteURL(repoToFork, protocol)
   302  			err = git.AddUpstreamRemote(upstreamURL, cloneDir, []string{})
   303  			if err != nil {
   304  				return err
   305  			}
   306  
   307  			if connectedToTerminal {
   308  				fmt.Fprintf(stderr, "%s Cloned fork\n", cs.SuccessIcon())
   309  			}
   310  		}
   311  	}
   312  
   313  	return nil
   314  }