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