When a repository argument is provided to `gh repo fork`, the command operates independently of the current local repository. Using --remote in this context is incompatible because there's no local repository to add the remote to. This change returns an explicit error when these flags are combined, providing clear feedback instead of silently ignoring the --remote flag. Fixes #2722 Signed-off-by: majiayu000 <1835304752@qq.com>
396 lines
12 KiB
Go
396 lines
12 KiB
Go
package fork
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/MakeNowJust/heredoc"
|
|
"github.com/cenkalti/backoff/v4"
|
|
"github.com/cli/cli/v2/api"
|
|
ghContext "github.com/cli/cli/v2/context"
|
|
"github.com/cli/cli/v2/git"
|
|
"github.com/cli/cli/v2/internal/gh"
|
|
"github.com/cli/cli/v2/internal/ghrepo"
|
|
"github.com/cli/cli/v2/pkg/cmd/repo/shared"
|
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
|
"github.com/cli/cli/v2/pkg/iostreams"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
const defaultRemoteName = "origin"
|
|
|
|
type iprompter interface {
|
|
Confirm(string, bool) (bool, error)
|
|
}
|
|
|
|
type ForkOptions struct {
|
|
HttpClient func() (*http.Client, error)
|
|
GitClient *git.Client
|
|
Config func() (gh.Config, error)
|
|
IO *iostreams.IOStreams
|
|
BaseRepo func() (ghrepo.Interface, error)
|
|
Remotes func() (ghContext.Remotes, error)
|
|
Since func(time.Time) time.Duration
|
|
BackOff backoff.BackOff
|
|
Prompter iprompter
|
|
|
|
GitArgs []string
|
|
Repository string
|
|
Clone bool
|
|
Remote bool
|
|
PromptClone bool
|
|
PromptRemote bool
|
|
RemoteName string
|
|
Organization string
|
|
ForkName string
|
|
Rename bool
|
|
DefaultBranchOnly bool
|
|
}
|
|
|
|
type errWithExitCode interface {
|
|
ExitCode() int
|
|
}
|
|
|
|
// TODO warn about useless flags (--remote, --remote-name) when running from outside a repository
|
|
// TODO output over STDOUT not STDERR
|
|
// TODO remote-name has no effect on its own; error that or change behavior
|
|
|
|
func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Command {
|
|
opts := &ForkOptions{
|
|
IO: f.IOStreams,
|
|
HttpClient: f.HttpClient,
|
|
GitClient: f.GitClient,
|
|
Config: f.Config,
|
|
BaseRepo: f.BaseRepo,
|
|
Remotes: f.Remotes,
|
|
Prompter: f.Prompter,
|
|
Since: time.Since,
|
|
}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "fork [<repository>] [-- <gitflags>...]",
|
|
Args: func(cmd *cobra.Command, args []string) error {
|
|
if cmd.ArgsLenAtDash() == 0 && len(args[1:]) > 0 {
|
|
return cmdutil.FlagErrorf("repository argument required when passing git clone flags")
|
|
}
|
|
return nil
|
|
},
|
|
Short: "Create a fork of a repository",
|
|
Long: heredoc.Docf(`
|
|
Create a fork of a repository.
|
|
|
|
With no argument, creates a fork of the current repository. Otherwise, forks
|
|
the specified repository.
|
|
|
|
By default, the new fork is set to be your %[1]sorigin%[1]s remote and any existing
|
|
origin remote is renamed to %[1]supstream%[1]s. To alter this behavior, you can set
|
|
a name for the new fork's remote with %[1]s--remote-name%[1]s.
|
|
|
|
The %[1]supstream%[1]s remote will be set as the default remote repository.
|
|
|
|
Additional %[1]sgit clone%[1]s flags can be passed after %[1]s--%[1]s.
|
|
`, "`"),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
promptOk := opts.IO.CanPrompt()
|
|
if len(args) > 0 {
|
|
opts.Repository = args[0]
|
|
opts.GitArgs = args[1:]
|
|
}
|
|
|
|
if cmd.Flags().Changed("org") && opts.Organization == "" {
|
|
return cmdutil.FlagErrorf("--org cannot be blank")
|
|
}
|
|
|
|
if opts.RemoteName == "" {
|
|
return cmdutil.FlagErrorf("--remote-name cannot be blank")
|
|
} else if !cmd.Flags().Changed("remote-name") {
|
|
opts.Rename = true // Any existing 'origin' will be renamed to upstream
|
|
}
|
|
|
|
if opts.Repository != "" && cmd.Flags().Changed("remote") {
|
|
return cmdutil.FlagErrorf("the `--remote` flag is unsupported when a repository argument is provided")
|
|
}
|
|
|
|
if promptOk {
|
|
// We can prompt for these if they were not specified.
|
|
opts.PromptClone = !cmd.Flags().Changed("clone")
|
|
opts.PromptRemote = !cmd.Flags().Changed("remote")
|
|
}
|
|
|
|
if runF != nil {
|
|
return runF(opts)
|
|
}
|
|
return forkRun(opts)
|
|
},
|
|
}
|
|
cmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
|
|
if err == pflag.ErrHelp {
|
|
return err
|
|
}
|
|
return cmdutil.FlagErrorf("%w\nSeparate git clone flags with `--`.", err)
|
|
})
|
|
|
|
cmd.Flags().BoolVar(&opts.Clone, "clone", false, "Clone the fork")
|
|
cmd.Flags().BoolVar(&opts.Remote, "remote", false, "Add a git remote for the fork")
|
|
cmd.Flags().StringVar(&opts.RemoteName, "remote-name", defaultRemoteName, "Specify the name for the new remote")
|
|
cmd.Flags().StringVar(&opts.Organization, "org", "", "Create the fork in an organization")
|
|
cmd.Flags().StringVar(&opts.ForkName, "fork-name", "", "Rename the forked repository")
|
|
cmd.Flags().BoolVar(&opts.DefaultBranchOnly, "default-branch-only", false, "Only include the default branch in the fork")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func forkRun(opts *ForkOptions) error {
|
|
var repoToFork ghrepo.Interface
|
|
var err error
|
|
inParent := false // whether or not we're forking the repo we're currently "in"
|
|
if opts.Repository == "" {
|
|
baseRepo, err := opts.BaseRepo()
|
|
if err != nil {
|
|
return fmt.Errorf("unable to determine base repository: %w", err)
|
|
}
|
|
inParent = true
|
|
repoToFork = baseRepo
|
|
} else {
|
|
repoArg := opts.Repository
|
|
|
|
if isURL(repoArg) {
|
|
parsedURL, err := url.Parse(repoArg)
|
|
if err != nil {
|
|
return fmt.Errorf("did not understand argument: %w", err)
|
|
}
|
|
|
|
repoToFork, err = ghrepo.FromURL(parsedURL)
|
|
if err != nil {
|
|
return fmt.Errorf("did not understand argument: %w", err)
|
|
}
|
|
|
|
} else if strings.HasPrefix(repoArg, "git@") {
|
|
parsedURL, err := git.ParseURL(repoArg)
|
|
if err != nil {
|
|
return fmt.Errorf("did not understand argument: %w", err)
|
|
}
|
|
repoToFork, err = ghrepo.FromURL(parsedURL)
|
|
if err != nil {
|
|
return fmt.Errorf("did not understand argument: %w", err)
|
|
}
|
|
} else {
|
|
repoToFork, err = ghrepo.FromFullName(repoArg)
|
|
if err != nil {
|
|
return fmt.Errorf("argument error: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
connectedToTerminal := opts.IO.IsStdoutTTY() && opts.IO.IsStderrTTY() && opts.IO.IsStdinTTY()
|
|
|
|
cs := opts.IO.ColorScheme()
|
|
stderr := opts.IO.ErrOut
|
|
|
|
httpClient, err := opts.HttpClient()
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create client: %w", err)
|
|
}
|
|
|
|
apiClient := api.NewClientFromHTTP(httpClient)
|
|
|
|
opts.IO.StartProgressIndicator()
|
|
forkedRepo, err := api.ForkRepo(apiClient, repoToFork, opts.Organization, opts.ForkName, opts.DefaultBranchOnly)
|
|
opts.IO.StopProgressIndicator()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fork: %w", err)
|
|
}
|
|
|
|
// This is weird. There is not an efficient way to determine via the GitHub API whether or not a
|
|
// given user has forked a given repo. We noticed, also, that the create fork API endpoint just
|
|
// returns the fork repo data even if it already exists -- with no change in status code or
|
|
// anything. We thus check the created time to see if the repo is brand new or not; if it's not,
|
|
// we assume the fork already existed and report an error.
|
|
createdAgo := opts.Since(forkedRepo.CreatedAt)
|
|
if createdAgo > time.Minute {
|
|
if connectedToTerminal {
|
|
fmt.Fprintf(stderr, "%s %s %s\n",
|
|
cs.Yellow("!"),
|
|
cs.Bold(ghrepo.FullName(forkedRepo)),
|
|
"already exists")
|
|
} else {
|
|
fmt.Fprintf(stderr, "%s already exists\n", ghrepo.FullName(forkedRepo))
|
|
}
|
|
} else {
|
|
if connectedToTerminal {
|
|
fmt.Fprintf(stderr, "%s Created fork %s\n", cs.SuccessIconWithColor(cs.Green), cs.Bold(ghrepo.FullName(forkedRepo)))
|
|
} else {
|
|
fmt.Fprintln(opts.IO.Out, ghrepo.GenerateRepoURL(forkedRepo, ""))
|
|
}
|
|
}
|
|
|
|
// Rename the new repo if necessary
|
|
if opts.ForkName != "" && !strings.EqualFold(forkedRepo.RepoName(), shared.NormalizeRepoName(opts.ForkName)) {
|
|
forkedRepo, err = api.RenameRepo(apiClient, forkedRepo, opts.ForkName)
|
|
if err != nil {
|
|
return fmt.Errorf("could not rename fork: %w", err)
|
|
}
|
|
if connectedToTerminal {
|
|
fmt.Fprintf(stderr, "%s Renamed fork to %s\n", cs.SuccessIconWithColor(cs.Green), cs.Bold(ghrepo.FullName(forkedRepo)))
|
|
}
|
|
}
|
|
|
|
if (inParent && (!opts.Remote && !opts.PromptRemote)) || (!inParent && (!opts.Clone && !opts.PromptClone)) {
|
|
return nil
|
|
}
|
|
|
|
cfg, err := opts.Config()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
protocolConfig := cfg.GitProtocol(repoToFork.RepoHost())
|
|
protocolIsConfiguredByUser := protocolConfig.Source == gh.ConfigUserProvided
|
|
protocol := protocolConfig.Value
|
|
|
|
gitClient := opts.GitClient
|
|
ctx := context.Background()
|
|
|
|
if inParent {
|
|
remotes, err := opts.Remotes()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !protocolIsConfiguredByUser {
|
|
if remote, err := remotes.FindByRepo(repoToFork.RepoOwner(), repoToFork.RepoName()); err == nil {
|
|
scheme := ""
|
|
if remote.FetchURL != nil {
|
|
scheme = remote.FetchURL.Scheme
|
|
}
|
|
if remote.PushURL != nil {
|
|
scheme = remote.PushURL.Scheme
|
|
}
|
|
if scheme != "" {
|
|
protocol = scheme
|
|
} else {
|
|
protocol = "https"
|
|
}
|
|
}
|
|
}
|
|
|
|
if remote, err := remotes.FindByRepo(forkedRepo.RepoOwner(), forkedRepo.RepoName()); err == nil {
|
|
if connectedToTerminal {
|
|
fmt.Fprintf(stderr, "%s Using existing remote %s\n", cs.SuccessIcon(), cs.Bold(remote.Name))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
remoteDesired := opts.Remote
|
|
if opts.PromptRemote {
|
|
remoteDesired, err = opts.Prompter.Confirm("Would you like to add a remote for the fork?", false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if remoteDesired {
|
|
remoteName := opts.RemoteName
|
|
remotes, err := opts.Remotes()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := remotes.FindByName(remoteName); err == nil {
|
|
if opts.Rename {
|
|
renameTarget := "upstream"
|
|
renameCmd, err := gitClient.Command(ctx, "remote", "rename", remoteName, renameTarget)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = renameCmd.Output()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if connectedToTerminal {
|
|
fmt.Fprintf(stderr, "%s Renamed remote %s to %s\n", cs.SuccessIcon(), cs.Bold(remoteName), cs.Bold(renameTarget))
|
|
}
|
|
} else {
|
|
return fmt.Errorf("a git remote named '%s' already exists", remoteName)
|
|
}
|
|
}
|
|
|
|
forkedRepoCloneURL := ghrepo.FormatRemoteURL(forkedRepo, protocol)
|
|
|
|
_, err = gitClient.AddRemote(ctx, remoteName, forkedRepoCloneURL, []string{})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add remote: %w", err)
|
|
}
|
|
|
|
if connectedToTerminal {
|
|
fmt.Fprintf(stderr, "%s Added remote %s\n", cs.SuccessIcon(), cs.Bold(remoteName))
|
|
}
|
|
}
|
|
} else {
|
|
cloneDesired := opts.Clone
|
|
if opts.PromptClone {
|
|
cloneDesired, err = opts.Prompter.Confirm("Would you like to clone the fork?", false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if cloneDesired {
|
|
// Allow injecting alternative BackOff in tests.
|
|
if opts.BackOff == nil {
|
|
bo := backoff.NewConstantBackOff(2 * time.Second)
|
|
opts.BackOff = bo
|
|
}
|
|
|
|
cloneDir, err := backoff.RetryWithData(func() (string, error) {
|
|
forkedRepoURL := ghrepo.FormatRemoteURL(forkedRepo, protocol)
|
|
dir, err := gitClient.Clone(ctx, forkedRepoURL, opts.GitArgs)
|
|
if err == nil {
|
|
return dir, nil
|
|
}
|
|
var execError errWithExitCode
|
|
if errors.As(err, &execError) && execError.ExitCode() == 128 {
|
|
return "", err
|
|
}
|
|
return "", backoff.Permanent(err)
|
|
}, backoff.WithContext(backoff.WithMaxRetries(opts.BackOff, 3), ctx))
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to clone fork: %w", err)
|
|
}
|
|
|
|
gc := gitClient.Copy()
|
|
gc.RepoDir = cloneDir
|
|
upstreamURL := ghrepo.FormatRemoteURL(repoToFork, protocol)
|
|
upstreamRemote := "upstream"
|
|
|
|
if _, err := gc.AddRemote(ctx, upstreamRemote, upstreamURL, []string{}); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := gc.SetRemoteResolution(ctx, upstreamRemote, "base"); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := gc.Fetch(ctx, upstreamRemote, ""); err != nil {
|
|
return err
|
|
}
|
|
|
|
if connectedToTerminal {
|
|
fmt.Fprintf(stderr, "%s Cloned fork\n", cs.SuccessIcon())
|
|
fmt.Fprintf(stderr, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(repoToFork)))
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func isURL(s string) bool {
|
|
return strings.HasPrefix(s, "http:/") || strings.HasPrefix(s, "https:/")
|
|
}
|