github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/repo/fork/fork.go (about)

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