diff --git a/cmd/commands/app.go b/cmd/commands/app.go index f34b138a..918d9686 100644 --- a/cmd/commands/app.go +++ b/cmd/commands/app.go @@ -52,8 +52,6 @@ type ( ) func NewAppCommand() *cobra.Command { - var cloneOpts *git.CloneOptions - cmd := &cobra.Command{ Use: "application", Aliases: []string{"app"}, @@ -63,19 +61,17 @@ func NewAppCommand() *cobra.Command { exit(1) }, } - cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ - FS: memfs.New(), - }) - cmd.AddCommand(NewAppCreateCommand(cloneOpts)) - cmd.AddCommand(NewAppListCommand(cloneOpts)) - cmd.AddCommand(NewAppDeleteCommand(cloneOpts)) + cmd.AddCommand(NewAppCreateCommand()) + cmd.AddCommand(NewAppListCommand()) + cmd.AddCommand(NewAppDeleteCommand()) return cmd } -func NewAppCreateCommand(cloneOpts *git.CloneOptions) *cobra.Command { +func NewAppCreateCommand() *cobra.Command { var ( + cloneOpts *git.CloneOptions appsCloneOpts *git.CloneOptions appOpts *application.CreateOptions projectName string @@ -145,6 +141,10 @@ func NewAppCreateCommand(cloneOpts *git.CloneOptions) *cobra.Command { cmd.Flags().StringVarP(&projectName, "project", "p", "", "Project name") cmd.Flags().DurationVar(&timeout, "wait-timeout", time.Duration(0), "If not '0s', will try to connect to the cluster and wait until the application is in 'Synced' status for the specified timeout period") + cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ + FS: memfs.New(), + CloneForWrite: true, + }) appsCloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ FS: memfs.New(), Prefix: "apps", @@ -314,7 +314,11 @@ func getCommitMsg(opts *AppCreateOptions, repofs fs.FS) string { return commitMsg } -func NewAppListCommand(cloneOpts *git.CloneOptions) *cobra.Command { +func NewAppListCommand() *cobra.Command { + var ( + cloneOpts *git.CloneOptions + ) + cmd := &cobra.Command{ Use: "list [PROJECT_NAME]", Short: "List all applications in a project", @@ -347,6 +351,10 @@ func NewAppListCommand(cloneOpts *git.CloneOptions) *cobra.Command { }, } + cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ + FS: memfs.New(), + }) + return cmd } @@ -394,8 +402,9 @@ func getConfigFileFromPath(repofs fs.FS, appPath string) (*application.Config, e return &conf, nil } -func NewAppDeleteCommand(cloneOpts *git.CloneOptions) *cobra.Command { +func NewAppDeleteCommand() *cobra.Command { var ( + cloneOpts *git.CloneOptions projectName string global bool ) @@ -441,6 +450,11 @@ func NewAppDeleteCommand(cloneOpts *git.CloneOptions) *cobra.Command { cmd.Flags().StringVarP(&projectName, "project", "p", "", "Project name") cmd.Flags().BoolVarP(&global, "global", "g", false, "global") + cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ + FS: memfs.New(), + CloneForWrite: true, + }) + return cmd } diff --git a/cmd/commands/common.go b/cmd/commands/common.go index 80f0b41e..fd53360f 100644 --- a/cmd/commands/common.go +++ b/cmd/commands/common.go @@ -45,6 +45,7 @@ var ( log.G(ctx).WithFields(log.Fields{ "repoURL": cloneOpts.URL(), "revision": cloneOpts.Revision(), + "forWrite": cloneOpts.CloneForWrite, }).Debug("starting with options: ") // clone repo diff --git a/cmd/commands/project.go b/cmd/commands/project.go index 9b9e1830..3ca13ad6 100644 --- a/cmd/commands/project.go +++ b/cmd/commands/project.go @@ -61,8 +61,6 @@ type ( ) func NewProjectCommand() *cobra.Command { - var cloneOpts *git.CloneOptions - cmd := &cobra.Command{ Use: "project", Aliases: []string{"proj"}, @@ -72,22 +70,20 @@ func NewProjectCommand() *cobra.Command { exit(1) }, } - cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ - FS: memfs.New(), - }) - cmd.AddCommand(NewProjectCreateCommand(cloneOpts)) - cmd.AddCommand(NewProjectListCommand(cloneOpts)) - cmd.AddCommand(NewProjectDeleteCommand(cloneOpts)) + cmd.AddCommand(NewProjectCreateCommand()) + cmd.AddCommand(NewProjectListCommand()) + cmd.AddCommand(NewProjectDeleteCommand()) return cmd } -func NewProjectCreateCommand(cloneOpts *git.CloneOptions) *cobra.Command { +func NewProjectCreateCommand() *cobra.Command { var ( kubeContext string dryRun bool addCmd argocd.AddClusterCmd + cloneOpts *git.CloneOptions ) cmd := &cobra.Command{ @@ -128,6 +124,10 @@ func NewProjectCreateCommand(cloneOpts *git.CloneOptions) *cobra.Command { cmd.Flags().StringVar(&kubeContext, "dest-kube-context", "", "The default destination kubernetes context for applications in this project") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "If true, print manifests instead of applying them to the cluster (nothing will be commited to git)") + cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ + FS: memfs.New(), + CloneForWrite: true, + }) addCmd, err := argocd.AddClusterAddFlags(cmd) die(err) @@ -350,7 +350,11 @@ func getDefaultAppLabels(labels map[string]string) map[string]string { return res } -func NewProjectListCommand(cloneOpts *git.CloneOptions) *cobra.Command { +func NewProjectListCommand() *cobra.Command { + var ( + cloneOpts *git.CloneOptions + ) + cmd := &cobra.Command{ Use: "list ", Short: "Lists all the projects on a git repository", @@ -378,6 +382,10 @@ func NewProjectListCommand(cloneOpts *git.CloneOptions) *cobra.Command { }, } + cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ + FS: memfs.New(), + }) + return cmd } @@ -418,7 +426,11 @@ var getProjectInfoFromFile = func(repofs fs.FS, name string) (*argocdv1alpha1.Ap return proj, appSet, nil } -func NewProjectDeleteCommand(cloneOpts *git.CloneOptions) *cobra.Command { +func NewProjectDeleteCommand() *cobra.Command { + var ( + cloneOpts *git.CloneOptions + ) + cmd := &cobra.Command{ Use: "delete [PROJECT_NAME]", Short: "Delete a project and all of its applications", @@ -451,6 +463,11 @@ func NewProjectDeleteCommand(cloneOpts *git.CloneOptions) *cobra.Command { }, } + cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ + FS: memfs.New(), + CloneForWrite: true, + }) + return cmd } diff --git a/cmd/commands/repo.go b/cmd/commands/repo.go index 79e4f144..1eb3a089 100644 --- a/cmd/commands/repo.go +++ b/cmd/commands/repo.go @@ -168,6 +168,7 @@ func NewRepoBootstrapCommand() *cobra.Command { cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ FS: memfs.New(), CreateIfNotExist: true, + CloneForWrite: true, }) // add kubernetes flags @@ -299,6 +300,7 @@ func NewRepoUninstallCommand() *cobra.Command { var ( cloneOpts *git.CloneOptions f kube.Factory + force bool ) cmd := &cobra.Command{ @@ -323,6 +325,12 @@ func NewRepoUninstallCommand() *cobra.Command { # and delete all manifests from a specific folder in the gitops repository repo uninstall --repo https://github.com/example/repo/path/to/installation_root + +# Uninstall using the --force flag will try to uninstall even if some steps +# failed. For example, if it cannot clone the bootstrap repo for some reason +# it will still attempt to delete argo-cd from the cluster. Use with caution! + + repo uninstall --repo https://github.com/example/repo --force `), PreRun: func(_ *cobra.Command, _ []string) { cloneOpts.Parse() }, RunE: func(cmd *cobra.Command, args []string) error { @@ -335,13 +343,17 @@ func NewRepoUninstallCommand() *cobra.Command { KubeContextName: kubeContextName, Timeout: util.MustParseDuration(cmd.Flag("request-timeout").Value.String()), CloneOptions: cloneOpts, + Force: force, KubeFactory: f, }) }, } + cmd.Flags().BoolVar(&force, "force", false, "If true, will try to complete the uninstallation even if one or more of the uninstallation steps failed") + cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ - FS: memfs.New(), + FS: memfs.New(), + CloneForWrite: true, }) f = kube.AddFlags(cmd.Flags()) diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index de22ac06..117ad695 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -28,12 +28,22 @@ If you want the autopilot-managed folder structure to reside under some sub-fold export GIT_REPO=https://github.com/owner/name/some/relative/path ``` -#### Using a Specific Revision +#### Using a Specific Branch If you want to use a specific branch for your GitOps repository operations, you can use the `ref` query parameter: ``` export GIT_REPO=https://github.com/owner/name?ref=gitops_branch ``` +!!! note + When running commands that commit or write to the repository, the value of `ref` can only be a branch. + + +!!! tip + When running commands that commit or write to the repository you may also specify the `-b`, this would create the branch specified in `ref` if it doesn't exist. + + Note that when doing so the new branch would be create from the default branch. + + #### Using a Specific git Provider You can add the `--provider` flag to the `repo bootstrap` command, to enforce using a specific provider when creating a new repository. If the value is not supplied, the code will attempt to infer it from the clone URL. Autopilot currently support github, gitlab, azure devops, and gitea as SCM providers. diff --git a/docs/commands/argocd-autopilot_application.md b/docs/commands/argocd-autopilot_application.md index a8892457..5bc3b9bb 100644 --- a/docs/commands/argocd-autopilot_application.md +++ b/docs/commands/argocd-autopilot_application.md @@ -9,10 +9,7 @@ argocd-autopilot application [flags] ### Options ``` - -t, --git-token string Your git provider api token [GIT_TOKEN] - -u, --git-user string Your git provider user name [GIT_USER] (not required in GitHub) - -h, --help help for application - --repo string Repository URL [GIT_REPO] + -h, --help help for application ``` ### SEE ALSO diff --git a/docs/commands/argocd-autopilot_application_create.md b/docs/commands/argocd-autopilot_application_create.md index 3abdac0e..7ef3b85b 100644 --- a/docs/commands/argocd-autopilot_application_create.md +++ b/docs/commands/argocd-autopilot_application_create.md @@ -55,24 +55,20 @@ argocd-autopilot application create [APP_NAME] [flags] --context string The name of the kubeconfig context to use --dest-namespace string K8s target namespace (overrides the namespace specified in the kustomization.yaml) --dest-server string K8s cluster URL (e.g. https://kubernetes.default.svc) (default "https://kubernetes.default.svc") + -t, --git-token string Your git provider api token [GIT_TOKEN] + -u, --git-user string Your git provider user name [GIT_USER] (not required in GitHub) -h, --help help for create --installation-mode string One of: normal|flat. If flat, will commit the application manifests (after running kustomize build), otherwise will commit the kustomization.yaml (default "normal") --kubeconfig string Path to the kubeconfig file to use for CLI requests. -n, --namespace string If present, the namespace scope for this CLI request -p, --project string Project name + --repo string Repository URL [GIT_REPO] --request-timeout string The length of time to wait before giving up on a single server request. Non-zero values should contain a corresponding time unit (e.g. 1s, 2m, 3h). A value of zero means don't timeout requests. (default "0") --type string The application type (kustomize|dir) + -b, --upsert-branch If true will try to checkout the specified branch and create it if it doesn't exist --wait-timeout duration If not '0s', will try to connect to the cluster and wait until the application is in 'Synced' status for the specified timeout period ``` -### Options inherited from parent commands - -``` - -t, --git-token string Your git provider api token [GIT_TOKEN] - -u, --git-user string Your git provider user name [GIT_USER] (not required in GitHub) - --repo string Repository URL [GIT_REPO] -``` - ### SEE ALSO * [argocd-autopilot application](argocd-autopilot_application.md) - Manage applications diff --git a/docs/commands/argocd-autopilot_application_delete.md b/docs/commands/argocd-autopilot_application_delete.md index 237660ff..88178070 100644 --- a/docs/commands/argocd-autopilot_application_delete.md +++ b/docs/commands/argocd-autopilot_application_delete.md @@ -28,18 +28,14 @@ argocd-autopilot application delete [APP_NAME] [flags] ### Options -``` - -g, --global global - -h, --help help for delete - -p, --project string Project name -``` - -### Options inherited from parent commands - ``` -t, --git-token string Your git provider api token [GIT_TOKEN] -u, --git-user string Your git provider user name [GIT_USER] (not required in GitHub) + -g, --global global + -h, --help help for delete + -p, --project string Project name --repo string Repository URL [GIT_REPO] + -b, --upsert-branch If true will try to checkout the specified branch and create it if it doesn't exist ``` ### SEE ALSO diff --git a/docs/commands/argocd-autopilot_application_list.md b/docs/commands/argocd-autopilot_application_list.md index d02b07ce..953e6bae 100644 --- a/docs/commands/argocd-autopilot_application_list.md +++ b/docs/commands/argocd-autopilot_application_list.md @@ -28,15 +28,10 @@ argocd-autopilot application list [PROJECT_NAME] [flags] ### Options -``` - -h, --help help for list -``` - -### Options inherited from parent commands - ``` -t, --git-token string Your git provider api token [GIT_TOKEN] -u, --git-user string Your git provider user name [GIT_USER] (not required in GitHub) + -h, --help help for list --repo string Repository URL [GIT_REPO] ``` diff --git a/docs/commands/argocd-autopilot_project.md b/docs/commands/argocd-autopilot_project.md index 325a487d..2230284c 100644 --- a/docs/commands/argocd-autopilot_project.md +++ b/docs/commands/argocd-autopilot_project.md @@ -9,10 +9,7 @@ argocd-autopilot project [flags] ### Options ``` - -t, --git-token string Your git provider api token [GIT_TOKEN] - -u, --git-user string Your git provider user name [GIT_USER] (not required in GitHub) - -h, --help help for project - --repo string Repository URL [GIT_REPO] + -h, --help help for project ``` ### SEE ALSO diff --git a/docs/commands/argocd-autopilot_project_create.md b/docs/commands/argocd-autopilot_project_create.md index 9eee5a55..b57a0a02 100644 --- a/docs/commands/argocd-autopilot_project_create.md +++ b/docs/commands/argocd-autopilot_project_create.md @@ -44,6 +44,8 @@ argocd-autopilot project create [PROJECT] [flags] --exec-command-args stringArray Arguments to supply to the --exec-command executable --exec-command-env stringToString Environment vars to set when running the --exec-command executable (default []) --exec-command-install-hint string Text shown to the user when the --exec-command executable doesn't seem to be present + -t, --git-token string Your git provider api token [GIT_TOKEN] + -u, --git-user string Your git provider user name [GIT_USER] (not required in GitHub) --grpc-web Enables gRPC-web protocol. Useful if Argo CD server is behind proxy which does not support HTTP2. --grpc-web-root-path string Enables gRPC-web protocol. Useful if Argo CD server is behind proxy which does not support HTTP2. Set web root. -H, --header strings Sets additional header to all requests made by Argo CD CLI. (Can be repeated multiple times to add multiple headers, also supports comma separated headers) @@ -55,23 +57,17 @@ argocd-autopilot project create [PROJECT] [flags] --plaintext Disable TLS --port-forward Connect to a random argocd-server port using port forwarding --port-forward-namespace string Namespace name which should be used for port forwarding + --repo string Repository URL [GIT_REPO] --server string Argo CD server address --server-crt string Server certificate file --service-account string System namespace service account to use for kubernetes resource management. If not set then default "argocd-manager" SA will be created --shard int Cluster shard number; inferred from hostname if not set (default -1) --system-namespace string Use different system namespace (default "kube-system") --upsert Override an existing cluster with the same name even if the spec differs + -b, --upsert-branch If true will try to checkout the specified branch and create it if it doesn't exist -y, --yes Skip explicit confirmation ``` -### Options inherited from parent commands - -``` - -t, --git-token string Your git provider api token [GIT_TOKEN] - -u, --git-user string Your git provider user name [GIT_USER] (not required in GitHub) - --repo string Repository URL [GIT_REPO] -``` - ### SEE ALSO * [argocd-autopilot project](argocd-autopilot_project.md) - Manage projects diff --git a/docs/commands/argocd-autopilot_project_delete.md b/docs/commands/argocd-autopilot_project_delete.md index 51436e0a..ec48e005 100644 --- a/docs/commands/argocd-autopilot_project_delete.md +++ b/docs/commands/argocd-autopilot_project_delete.md @@ -28,16 +28,12 @@ argocd-autopilot project delete [PROJECT_NAME] [flags] ### Options -``` - -h, --help help for delete -``` - -### Options inherited from parent commands - ``` -t, --git-token string Your git provider api token [GIT_TOKEN] -u, --git-user string Your git provider user name [GIT_USER] (not required in GitHub) + -h, --help help for delete --repo string Repository URL [GIT_REPO] + -b, --upsert-branch If true will try to checkout the specified branch and create it if it doesn't exist ``` ### SEE ALSO diff --git a/docs/commands/argocd-autopilot_project_list.md b/docs/commands/argocd-autopilot_project_list.md index 4927f40b..2cef0e8d 100644 --- a/docs/commands/argocd-autopilot_project_list.md +++ b/docs/commands/argocd-autopilot_project_list.md @@ -28,15 +28,10 @@ argocd-autopilot project list [flags] ### Options -``` - -h, --help help for list -``` - -### Options inherited from parent commands - ``` -t, --git-token string Your git provider api token [GIT_TOKEN] -u, --git-user string Your git provider user name [GIT_USER] (not required in GitHub) + -h, --help help for list --repo string Repository URL [GIT_REPO] ``` diff --git a/docs/commands/argocd-autopilot_repo_bootstrap.md b/docs/commands/argocd-autopilot_repo_bootstrap.md index e3919acd..5681b114 100644 --- a/docs/commands/argocd-autopilot_repo_bootstrap.md +++ b/docs/commands/argocd-autopilot_repo_bootstrap.md @@ -48,6 +48,7 @@ argocd-autopilot repo bootstrap [flags] --provider string The git provider, one of: azure|gitea|github|gitlab --repo string Repository URL [GIT_REPO] --request-timeout string The length of time to wait before giving up on a single server request. Non-zero values should contain a corresponding time unit (e.g. 1s, 2m, 3h). A value of zero means don't timeout requests. (default "0") + -b, --upsert-branch If true will try to checkout the specified branch and create it if it doesn't exist ``` ### SEE ALSO diff --git a/docs/commands/argocd-autopilot_repo_uninstall.md b/docs/commands/argocd-autopilot_repo_uninstall.md index 6fc24fb1..714d306a 100644 --- a/docs/commands/argocd-autopilot_repo_uninstall.md +++ b/docs/commands/argocd-autopilot_repo_uninstall.md @@ -29,12 +29,19 @@ argocd-autopilot repo uninstall [flags] argocd-autopilot repo uninstall --repo https://github.com/example/repo/path/to/installation_root +# Uninstall using the --force flag will try to uninstall even if some steps +# failed. For example, if it cannot clone the bootstrap repo for some reason +# it will still attempt to delete argo-cd from the cluster. Use with caution! + + argocd-autopilot repo uninstall --repo https://github.com/example/repo --force + ``` ### Options ``` --context string The name of the kubeconfig context to use + --force If true, will try to complete the uninstallation even if one or more of the uninstallation steps failed -t, --git-token string Your git provider api token [GIT_TOKEN] -u, --git-user string Your git provider user name [GIT_USER] (not required in GitHub) -h, --help help for uninstall @@ -42,6 +49,7 @@ argocd-autopilot repo uninstall [flags] -n, --namespace string If present, the namespace scope for this CLI request --repo string Repository URL [GIT_REPO] --request-timeout string The length of time to wait before giving up on a single server request. Non-zero values should contain a corresponding time unit (e.g. 1s, 2m, 3h). A value of zero means don't timeout requests. (default "0") + -b, --upsert-branch If true will try to checkout the specified branch and create it if it doesn't exist ``` ### SEE ALSO diff --git a/pkg/git/repository.go b/pkg/git/repository.go index 0a767279..e28ba04b 100644 --- a/pkg/git/repository.go +++ b/pkg/git/repository.go @@ -44,7 +44,10 @@ type ( FS billy.Filesystem Prefix string CreateIfNotExist bool - Optional bool + // CloneForWrite if true will not allow 'ref' query param which is not + // a branch name + CloneForWrite bool + Optional bool } CloneOptions struct { @@ -54,6 +57,8 @@ type ( FS fs.FS Progress io.Writer CreateIfNotExist bool + CloneForWrite bool + UpsertBranch bool url string revision string path string @@ -88,8 +93,12 @@ const ( // go-git functions (we mock those in tests) var ( - checkoutBranch = func(r *repo, branch string, createIfNotExists bool) error { - return r.checkoutBranch(branch, createIfNotExists) + checkoutRef = func(r *repo, ref string) error { + return r.checkoutRef(ref) + } + + checkoutBranch = func(r *repo, branch string, upsertBranch bool) error { + return r.checkoutBranch(branch, upsertBranch) } ggClone = func(ctx context.Context, s storage.Storer, worktree billy.Filesystem, o *gg.CloneOptions) (gogit.Repository, error) { @@ -122,6 +131,7 @@ func AddFlags(cmd *cobra.Command, opts *AddFlagsOptions) *CloneOptions { co := &CloneOptions{ FS: fs.Create(opts.FS), CreateIfNotExist: opts.CreateIfNotExist, + CloneForWrite: opts.CloneForWrite, } if opts.Prefix != "" && !strings.HasSuffix(opts.Prefix, "-") { @@ -146,6 +156,10 @@ func AddFlags(cmd *cobra.Command, opts *AddFlagsOptions) *CloneOptions { cmd.PersistentFlags().StringVar(&co.Provider, opts.Prefix+"provider", "", fmt.Sprintf("The git provider, one of: %v", strings.Join(Providers(), "|"))) } + if opts.CloneForWrite { + cmd.PersistentFlags().BoolVarP(&co.UpsertBranch, opts.Prefix+"upsert-branch", "b", false, "If true will try to checkout the specified branch and create it if it doesn't exist") + } + if !opts.Optional { util.Die(cmd.MarkPersistentFlagRequired(opts.Prefix + "git-token")) util.Die(cmd.MarkPersistentFlagRequired(opts.Prefix + "repo")) @@ -355,8 +369,21 @@ var clone = func(ctx context.Context, opts *CloneOptions) (*repo, error) { repo := &repo{Repository: r, auth: opts.Auth, progress: progress} if opts.revision != "" { - if err := checkoutBranch(repo, opts.revision, opts.CreateIfNotExist); err != nil { - return nil, err + if opts.CloneForWrite { + log.G(ctx).WithFields(log.Fields{ + "branch": opts.revision, + "upsert": opts.UpsertBranch, + }).Debug("Trying to checkout branch") + + if err := checkoutBranch(repo, opts.revision, opts.UpsertBranch); err != nil { + return nil, err + } + } else { + log.G(ctx).WithField("ref", opts.revision).Debug("Trying to checkout ref") + + if err := checkoutRef(repo, opts.revision); err != nil { + return nil, err + } } } @@ -438,7 +465,7 @@ var initRepo = func(ctx context.Context, opts *CloneOptions) (*repo, error) { return r, r.initBranch(ctx, opts.revision) } -func (r *repo) checkoutBranch(branch string, createIfNotExists bool) error { +func (r *repo) checkoutBranch(branch string, upsertBranch bool) error { wt, err := worktree(r) if err != nil { return err @@ -464,7 +491,7 @@ func (r *repo) checkoutBranch(branch string, createIfNotExists bool) error { Branch: plumbing.NewRemoteReferenceName(remotes[0].Config().Name, branch), }) if err != nil { - if err == plumbing.ErrReferenceNotFound && createIfNotExists { + if err == plumbing.ErrReferenceNotFound && upsertBranch { // no remote branch but create is true // so we will create a new local branch return wt.Checkout(&gg.CheckoutOptions{ @@ -485,6 +512,44 @@ func (r *repo) checkoutBranch(branch string, createIfNotExists bool) error { }) } +func (r *repo) checkoutRef(ref string) error { + hash, err := r.ResolveRevision(plumbing.Revision(ref)) + if err != nil { + if err != plumbing.ErrReferenceNotFound { + return err + } + + log.G().WithField("ref", ref).Debug("failed resolving ref, trying to resolve from remote branch") + remotes, err := r.Remotes() + if err != nil { + return err + } + + if len(remotes) == 0 { + return ErrNoRemotes + } + + remoteref := fmt.Sprintf("%s/%s", remotes[0].Config().Name, ref) + hash, err = r.ResolveRevision(plumbing.Revision(remoteref)) + if err != nil { + return err + } + } + + wt, err := worktree(r) + if err != nil { + return err + } + + log.G().WithFields(log.Fields{ + "ref": ref, + "hash": hash.String(), + }).Debug("checking out commit") + return wt.Checkout(&gg.CheckoutOptions{ + Hash: *hash, + }) +} + func (r *repo) addRemote(name, url string) error { _, err := r.CreateRemote(&config.RemoteConfig{Name: name, URLs: []string{url}}) return err diff --git a/pkg/git/repository_test.go b/pkg/git/repository_test.go index c44e7d7d..826ce46c 100644 --- a/pkg/git/repository_test.go +++ b/pkg/git/repository_test.go @@ -258,7 +258,8 @@ func Test_clone(t *testing.T) { retErr error wantErr bool expectedOpts *gg.CloneOptions - checkoutBranch func(t *testing.T, r *repo, branch string, createIfNotExists bool) error + checkoutRef func(t *testing.T, r *repo, ref string) error + checkoutBranch func(t *testing.T, r *repo, branch string, upsert bool) error assertFn func(t *testing.T, r *repo, cloneCalls int) }{ "Should fail when there are no CloneOptions": { @@ -326,8 +327,8 @@ func Test_clone(t *testing.T) { Depth: 1, Progress: os.Stderr, }, - checkoutBranch: func(t *testing.T, r *repo, branch string, createIfNotExists bool) error { - assert.Equal(t, "test", branch) + checkoutRef: func(t *testing.T, _ *repo, ref string) error { + assert.Equal(t, "test", ref) return nil }, assertFn: func(t *testing.T, r *repo, cloneCalls int) { @@ -344,14 +345,39 @@ func Test_clone(t *testing.T) { Progress: os.Stderr, }, wantErr: true, - checkoutBranch: func(t *testing.T, r *repo, branch string, createIfNotExists bool) error { - assert.Equal(t, "test", branch) + checkoutRef: func(t *testing.T, _ *repo, ref string) error { + assert.Equal(t, "test", ref) return errors.New("some error") }, assertFn: func(t *testing.T, r *repo, cloneCalls int) { assert.Nil(t, r) }, }, + "Should try to upsert branch if upsert branch and cloneForWrite are set": { + opts: &CloneOptions{ + Repo: "https://github.com/owner/name?ref=test", + UpsertBranch: true, + CloneForWrite: true, + }, + expectedOpts: &gg.CloneOptions{ + URL: "https://github.com/owner/name.git", + Depth: 1, + Progress: os.Stderr, + }, + checkoutRef: func(t *testing.T, _ *repo, ref string) error { + // should not call this function + assert.Equal(t, true, false) + return nil + }, + checkoutBranch: func(t *testing.T, r *repo, branch string, upsert bool) error { + assert.Equal(t, branch, "test") + assert.Equal(t, upsert, true) + return nil + }, + assertFn: func(t *testing.T, r *repo, cloneCalls int) { + assert.NotNil(t, r) + }, + }, "Should retry if fails with 'repo not found' error": { opts: &CloneOptions{ Repo: "https://github.com/owner/name", @@ -389,9 +415,9 @@ func Test_clone(t *testing.T) { }, } - origCheckoutBranch, origClone := checkoutBranch, ggClone + origCheckoutRef, origClone := checkoutRef, ggClone defer func() { - checkoutBranch = origCheckoutBranch + checkoutRef = origCheckoutRef ggClone = origClone }() @@ -416,9 +442,15 @@ func Test_clone(t *testing.T) { tt.opts.Parse() } + if tt.checkoutRef != nil { + checkoutRef = func(r *repo, ref string) error { + return tt.checkoutRef(t, r, ref) + } + } + if tt.checkoutBranch != nil { - checkoutBranch = func(r *repo, branch string, createIfNotExists bool) error { - return tt.checkoutBranch(t, r, branch, false) + checkoutBranch = func(r *repo, branch string, upsertBranch bool) error { + return tt.checkoutBranch(t, r, branch, upsertBranch) } } @@ -677,6 +709,127 @@ func Test_repo_Persist(t *testing.T) { } func Test_repo_checkoutRef(t *testing.T) { + tests := map[string]struct { + ref string + hash string + wantErr string + beforeFn func() *mocks.Repository + }{ + "Should checkout a specific hash": { + ref: "3992c4", + hash: "3992c4", + beforeFn: func() *mocks.Repository { + r := &mocks.Repository{} + hash := plumbing.NewHash("3992c4") + r.On("ResolveRevision", plumbing.Revision("3992c4")).Return(&hash, nil) + return r + }, + }, + "Should checkout a tag": { + ref: "v1.0.0", + hash: "3992c4", + beforeFn: func() *mocks.Repository { + r := &mocks.Repository{} + hash := plumbing.NewHash("3992c4") + r.On("ResolveRevision", plumbing.Revision("v1.0.0")).Return(&hash, nil) + return r + }, + }, + "Should checkout a branch": { + ref: "CR-1234", + hash: "3992c4", + beforeFn: func() *mocks.Repository { + r := &mocks.Repository{} + r.On("ResolveRevision", plumbing.Revision("CR-1234")).Return(nil, plumbing.ErrReferenceNotFound) + r.On("Remotes").Return([]*gg.Remote{ + gg.NewRemote(nil, &config.RemoteConfig{ + Name: "origin", + }), + }, nil) + hash := plumbing.NewHash("3992c4") + r.On("ResolveRevision", plumbing.Revision("origin/CR-1234")).Return(&hash, nil) + return r + }, + }, + "Should fail if ResolveRevision fails": { + ref: "CR-1234", + hash: "3992c4", + wantErr: "some error", + beforeFn: func() *mocks.Repository { + r := &mocks.Repository{} + r.On("ResolveRevision", plumbing.Revision("CR-1234")).Return(nil, errors.New("some error")) + return r + }, + }, + "Should fail if Remotes fails": { + ref: "CR-1234", + hash: "3992c4", + wantErr: "some error", + beforeFn: func() *mocks.Repository { + r := &mocks.Repository{} + r.On("ResolveRevision", plumbing.Revision("CR-1234")).Return(nil, plumbing.ErrReferenceNotFound) + r.On("Remotes").Return(nil, errors.New("some error")) + return r + }, + }, + "Should fail if repo has no remotes": { + ref: "CR-1234", + hash: "3992c4", + wantErr: ErrNoRemotes.Error(), + beforeFn: func() *mocks.Repository { + r := &mocks.Repository{} + r.On("ResolveRevision", plumbing.Revision("CR-1234")).Return(nil, plumbing.ErrReferenceNotFound) + r.On("Remotes").Return([]*gg.Remote{}, nil) + return r + }, + }, + "Should fail if branch not found": { + ref: "CR-1234", + hash: "3992c4", + wantErr: plumbing.ErrReferenceNotFound.Error(), + beforeFn: func() *mocks.Repository { + r := &mocks.Repository{} + r.On("ResolveRevision", plumbing.Revision("CR-1234")).Return(nil, plumbing.ErrReferenceNotFound) + r.On("Remotes").Return([]*gg.Remote{ + gg.NewRemote(nil, &config.RemoteConfig{ + Name: "origin", + }), + }, nil) + r.On("ResolveRevision", plumbing.Revision("origin/CR-1234")).Return(nil, plumbing.ErrReferenceNotFound) + return r + }, + }, + } + origWorktree := worktree + defer func() { worktree = origWorktree }() + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + mockwt := &mocks.Worktree{} + worktree = func(r gogit.Repository) (gogit.Worktree, error) { + return mockwt, nil + } + mockwt.On("Checkout", &gg.CheckoutOptions{ + Hash: plumbing.NewHash(tt.hash), + }).Return(nil) + mockrepo := tt.beforeFn() + r := &repo{Repository: mockrepo} + if err := r.checkoutRef(tt.ref); err != nil { + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + t.Errorf("repo.checkoutRef() error = %v, wantErr %v", err, tt.wantErr) + } + + return + } + + mockrepo.AssertExpectations(t) + mockwt.AssertExpectations(t) + }) + } +} + +func Test_repo_checkoutBranch(t *testing.T) { tests := map[string]struct { ref string createIfNotExists bool