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 }