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 }