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