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 }