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