Repository: cli/cli
Pull request #9905 (2024-11-11 15:23:19)

Support bare repo creation
## Description

Fixes https://github.com/cli/cli/issues/9904

### Acceptance Criteria

**Given** My cwd is a bare git repository
**When** I run `gh repo create`, and I choose the option to push it to GitHub
**Then** It succeeds in creating and pushing the repo, mirroring all refs

```
➜  cli-triaging git:(main) ✗ cd non-bare

➜  non-bare git:(feature) git branch | cat
* feature
  main

➜  non-bare git:(feature) cd ..

➜  cli-triaging git:(main) ✗ git clone --bare non-bare bare
Cloning into bare repository 'bare'...
done.

➜  cli-triaging git:(main) ✗ cd bare

➜  bare git:(feature) ~/workspace/cli/bin/gh repo create
? What would you like to do? Push an existing local repository to GitHub
? Path to local repository .
? Repository name bare
? Repository owner williammartin-test-org
? Description
? Visibility Private
✓ Created repository williammartin-test-org/bare on GitHub
  https://github.com/williammartin-test-org/bare
? Add a remote? Yes
? What should the new remote be called? bare
✓ Added remote https://github.com/williammartin-test-org/bare.git
? Would you like to mirror all refs to "bare"? Yes
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Delta compression using up to 10 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 242 bytes | 242.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (1/1), done.
To https://github.com/williammartin-test-org/bare.git
 * [new branch]      feature -> feature
 * [new branch]      main -> main
✓ Mirrored all refs to https://github.com/williammartin-test-org/bare.git

➜  bare git:(feature) gh api /repos/williammartin-test-org/bare/branches | cat
[{"name":"feature","commit":{"sha":"fca77ed5e5be49ff42779f98061bcd0453f9649e","url":"https://api.github.com/repos/williammartin-test-org/bare/commits/fca77ed5e5be49ff42779f98061bcd0453f9649e"},"protected":false},{"name":"main","commit":{"sha":"fcf697da1ed589b88124193e59ca6ba3243a243c","url":"https://api.github.com/repos/williammartin-test-org/bare/commits/fcf697da1ed589b88124193e59ca6ba3243a243c"},"protected":false}]%
```

**Given** My cwd is a bare git repository
**When** I run `gh repo create --source . --push --private`
**Then** It succeeds in creating and pushing the repo, mirroring all refs

```
➜  cli-triaging git:(main) ✗ cd non-bare

➜  non-bare git:(feature) git branch | cat
* feature
  main

➜  non-bare git:(feature) cd ..

➜  cli-triaging git:(main) ✗ git clone --bare non-bare bare
Cloning into bare repository 'bare'...
done.

➜  bare git:(feature) ~/workspace/cli/bin/gh repo create williammartin-test-org/bare --source . --push --private --remote bare
✓ Created repository williammartin-test-org/bare on GitHub
  https://github.com/williammartin-test-org/bare
✓ Added remote https://github.com/williammartin-test-org/bare.git
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Delta compression using up to 10 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 242 bytes | 242.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (1/1), done.
To https://github.com/williammartin-test-org/bare.git
 * [new branch]      feature -> feature
 * [new branch]      main -> main
✓ Mirrored all refs to https://github.com/williammartin-test-org/bare.git

➜  bare git:(feature) gh api /repos/williammartin-test-org/bare/branches | cat
[{"name":"feature","commit":{"sha":"fca77ed5e5be49ff42779f98061bcd0453f9649e","url":"https://api.github.com/repos/williammartin-test-org/bare/commits/fca77ed5e5be49ff42779f98061bcd0453f9649e"},"protected":false},{"name":"main","commit":{"sha":"fcf697da1ed589b88124193e59ca6ba3243a243c","url":"https://api.github.com/repos/williammartin-test-org/bare/commits/fcf697da1ed589b88124193e59ca6ba3243a243c"},"protected":false}]%
```

### Acceptance Test

I also created an Acceptance Test:

```
➜ set -o pipefail | GH_ACCEPTANCE_SCRIPT=repo-create-bare.txtar GH_ACCEPTANCE_HOST=github.com GH_ACCEPTANCE_ORG=gh-acceptance-testing go test -tags acceptance -run '^TestRepo' -json github.com/cli/cli/v2/acceptance | tparse --all go test
┌───────────────────────────────────────────────────────────────────────────────────┐
│  STATUS │ ELAPSED │           TEST            │             PACKAGE               │
│─────────┼─────────┼───────────────────────────┼───────────────────────────────────│
│  PASS   │    4.23 │ TestRepo/repo-create-bare │ github.com/cli/cli/v2/acceptance  │
│  PASS   │    0.00 │ TestRepo                  │ github.com/cli/cli/v2/acceptance  │
└───────────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────────────┐
│  STATUS │ ELAPSED │             PACKAGE              │ COVER │ PASS │ FAIL │ SKIP  │
│─────────┼─────────┼──────────────────────────────────┼───────┼──────┼──────┼───────│
│  PASS   │  4.82s  │ github.com/cli/cli/v2/acceptance │  --   │  2   │  0   │  0    │
└────────────────────────────────────────────────────────────────────────────────────┘
```


Patch:
diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go
index 2fc127aba..79c349aa4 100644
--- a/pkg/cmd/repo/create/create.go
+++ b/pkg/cmd/repo/create/create.go
@@ -94,7 +94,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
 			To create a remote repository from an existing local repository, specify the source directory with %[1]s--source%[1]s.
 			By default, the remote repository name will be the name of the source directory.
 
-			Pass %[1]s--push%[1]s to push any local commits to the new repository.
+			Pass %[1]s--push%[1]s to push any local commits to the new repository. If the repo is bare, this will mirror all refs.
 
 			For language or platform .gitignore templates to use with %[1]s--gitignore%[1]s, <https://github.com/github/gitignore>.
 
@@ -556,11 +556,11 @@ func createFromLocal(opts *CreateOptions) error {
 		return err
 	}
 
-	isRepo, err := isLocalRepo(opts.GitClient)
+	repoType, err := localRepoType(opts.GitClient)
 	if err != nil {
 		return err
 	}
-	if !isRepo {
+	if repoType == unknown {
 		if repoPath == "." {
 			return fmt.Errorf("current directory is not a git repository. Run `git init` to initialize it")
 		}
@@ -652,22 +652,43 @@ func createFromLocal(opts *CreateOptions) error {
 
 	// don't prompt for push if there are no commits
 	if opts.Interactive && committed {
+		msg := fmt.Sprintf("Would you like to push commits from the current branch to %q?", baseRemote)
+		if repoType == bare {
+			msg = fmt.Sprintf("Would you like to mirror all refs to %q?", baseRemote)
+		}
+
 		var err error
-		opts.Push, err = opts.Prompter.Confirm(fmt.Sprintf("Would you like to push commits from the current branch to %q?", baseRemote), true)
+		opts.Push, err = opts.Prompter.Confirm(msg, true)
 		if err != nil {
 			return err
 		}
 	}
 
-	if opts.Push {
+	if opts.Push && repoType == working {
 		err := opts.GitClient.Push(context.Background(), baseRemote, "HEAD")
 		if err != nil {
 			return err
 		}
+
 		if isTTY {
 			fmt.Fprintf(stdout, "%s Pushed commits to %s\n", cs.SuccessIcon(), remoteURL)
 		}
 	}
+
+	if opts.Push && repoType == bare {
+		cmd, err := opts.GitClient.AuthenticatedCommand(context.Background(), "push", baseRemote, "--mirror")
+		if err != nil {
+			return err
+		}
+		if err = cmd.Run(); err != nil {
+			return err
+		}
+
+		if isTTY {
+			fmt.Fprintf(stdout, "%s Mirrored all refs to %s\n", cs.SuccessIcon(), remoteURL)
+		}
+	}
+
 	return nil
 }
 
@@ -736,22 +757,34 @@ func hasCommits(gitClient *git.Client) (bool, error) {
 	return false, nil
 }
 
-// check if path is the top level directory of a git repo
-func isLocalRepo(gitClient *git.Client) (bool, error) {
+type repoType int
+
+const (
+	unknown repoType = iota
+	working
+	bare
+)
+
+func localRepoType(gitClient *git.Client) (repoType, error) {
 	projectDir, projectDirErr := gitClient.GitDir(context.Background())
 	if projectDirErr != nil {
-		var execError *exec.ExitError
+		var execError errWithExitCode
 		if errors.As(projectDirErr, &execError) {
 			if exitCode := int(execError.ExitCode()); exitCode == 128 {
-				return false, nil
+				return unknown, nil
 			}
-			return false, projectDirErr
+			return unknown, projectDirErr
 		}
 	}
-	if projectDir != ".git" {
-		return false, nil
+
+	switch projectDir {
+	case ".":
+		return bare, nil
+	case ".git":
+		return working, nil
+	default:
+		return unknown, nil
 	}
-	return true, nil
 }
 
 // clone the checkout branch to specified path


Test patch:
diff --git a/acceptance/testdata/repo/repo-create-bare.txtar b/acceptance/testdata/repo/repo-create-bare.txtar
new file mode 100644
index 000000000..b835c420b
--- /dev/null
+++ b/acceptance/testdata/repo/repo-create-bare.txtar
@@ -0,0 +1,35 @@
+# It's unclear what we want to do with these acceptance tests beyond our GHEC discovery, so skip new ones by default
+skip
+
+# Set up env var
+env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
+
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Initialise a local repository with two branches
+# We expect a bare repo to have all refs pushed with --mirror
+mkdir ${REPO}
+cd ${REPO}
+exec git init
+exec git checkout -b feature-1
+exec git commit --allow-empty -m 'Empty Commit 1'
+
+exec git checkout -b feature-2
+exec git commit --allow-empty -m 'Empty Commit 2'
+
+# Clone a bare repo from that local repo
+cd ..
+exec git clone --bare ${REPO} ${REPO}-bare
+cd ${REPO}-bare
+
+# Create a GitHub repository from that bare repo
+exec gh repo create ${ORG}/${REPO} --private --source . --push --remote bare
+
+# Defer repo cleanup
+defer gh repo delete --yes ${ORG}/${REPO}
+
+# Check the remote repo has both branches
+exec gh api /repos/${ORG}/${REPO}/branches
+stdout 'feature-1'
+stdout 'feature-2'
diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go
index cc0ec602a..c33cfdad6 100644
--- a/pkg/cmd/repo/create/create_test.go
+++ b/pkg/cmd/repo/create/create_test.go
@@ -443,6 +443,74 @@ func Test_createRun(t *testing.T) {
 			},
 			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n  https://github.com/OWNER/REPO\n",
 		},
+		{
+			name: "interactive with existing bare repository public and push",
+			opts: &CreateOptions{Interactive: true},
+			tty:  true,
+			promptStubs: func(p *prompter.PrompterMock) {
+				p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) {
+					switch message {
+					case "Add a remote?":
+						return true, nil
+					case `Would you like to mirror all refs to "origin"?`:
+						return true, nil
+					default:
+						return false, fmt.Errorf("unexpected confirm prompt: %s", message)
+					}
+				}
+				p.InputFunc = func(message, defaultValue string) (string, error) {
+					switch message {
+					case "Path to local repository":
+						return defaultValue, nil
+					case "Repository name":
+						return "REPO", nil
+					case "Description":
+						return "my new repo", nil
+					case "What should the new remote be called?":
+						return defaultValue, nil
+					default:
+						return "", fmt.Errorf("unexpected input prompt: %s", message)
+					}
+				}
+				p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
+					switch message {
+					case "What would you like to do?":
+						return prompter.IndexFor(options, "Push an existing local repository to GitHub")
+					case "Visibility":
+						return prompter.IndexFor(options, "Private")
+					default:
+						return 0, fmt.Errorf("unexpected select prompt: %s", message)
+					}
+				}
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query UserCurrent\b`),
+					httpmock.StringResponse(`{"data":{"viewer":{"login":"someuser","organizations":{"nodes": []}}}}`))
+				reg.Register(
+					httpmock.GraphQL(`mutation RepositoryCreate\b`),
+					httpmock.StringResponse(`
+					{
+						"data": {
+							"createRepository": {
+								"repository": {
+									"id": "REPOID",
+									"name": "REPO",
+									"owner": {"login":"OWNER"},
+									"url": "https://github.com/OWNER/REPO"
+								}
+							}
+						}
+					}`))
+			},
+			execStubs: func(cs *run.CommandStubber) {
+				cs.Register(`git -C . rev-parse --git-dir`, 0, ".")
+				cs.Register(`git -C . rev-parse HEAD`, 0, "commithash")
+				cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "")
+				cs.Register(`git -C . push origin --mirror`, 0, "")
+			},
+			wantStdout: "✓ Created repository OWNER/REPO on GitHub\n  https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Mirrored all refs to https://github.com/OWNER/REPO.git\n",
+		},
 		{
 			name: "interactive with existing repository public add remote and push",
 			opts: &CreateOptions{Interactive: true},
@@ -696,6 +764,71 @@ func Test_createRun(t *testing.T) {
 			},
 			wantStdout: "https://github.com/OWNER/REPO\n",
 		},
+		{
+			name: "noninteractive create bare from source and push",
+			opts: &CreateOptions{
+				Interactive: false,
+				Source:      ".",
+				Push:        true,
+				Name:        "REPO",
+				Visibility:  "PRIVATE",
+			},
+			tty: false,
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`mutation RepositoryCreate\b`),
+					httpmock.StringResponse(`
+						{
+							"data": {
+								"createRepository": {
+									"repository": {
+										"id": "REPOID",
+										"name": "REPO",
+										"owner": {"login":"OWNER"},
+										"url": "https://github.com/OWNER/REPO"
+									}
+								}
+							}
+						}`))
+			},
+			execStubs: func(cs *run.CommandStubber) {
+				cs.Register(`git -C . rev-parse --git-dir`, 0, ".")
+				cs.Register(`git -C . rev-parse HEAD`, 0, "commithash")
+				cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "")
+				cs.Register(`git -C . push origin --mirror`, 0, "")
+			},
+			wantStdout: "https://github.com/OWNER/REPO\n",
+		},
+		{
+			name: "noninteractive create from cwd that isn't a git repo",
+			opts: &CreateOptions{
+				Interactive: false,
+				Source:      ".",
+				Name:        "REPO",
+				Visibility:  "PRIVATE",
+			},
+			tty: false,
+			execStubs: func(cs *run.CommandStubber) {
+				cs.Register(`git -C . rev-parse --git-dir`, 128, "")
+			},
+			wantErr: true,
+			errMsg:  "current directory is not a git repository. Run `git init` to initialize it",
+		},
+		{
+			name: "noninteractive create from cwd that isn't a git repo",
+			opts: &CreateOptions{
+				Interactive: false,
+				Source:      "some-dir",
+				Name:        "REPO",
+				Visibility:  "PRIVATE",
+			},
+			tty: false,
+			execStubs: func(cs *run.CommandStubber) {
+				cs.Register(`git -C some-dir rev-parse --git-dir`, 128, "")
+			},
+			wantErr: true,
+			errMsg:  "some-dir is not a git repository. Run `git -C \"some-dir\" init` to initialize it",
+		},
 		{
 			name: "noninteractive clone from scratch",
 			opts: &CreateOptions{
@@ -856,11 +989,11 @@ func Test_createRun(t *testing.T) {
 			defer reg.Verify(t)
 			err := createRun(tt.opts)
 			if tt.wantErr {
-				assert.Error(t, err)
-				assert.Equal(t, tt.errMsg, err.Error())
+				require.Error(t, err)
+				assert.Contains(t, err.Error(), tt.errMsg)
 				return
 			}
-			assert.NoError(t, err)
+			require.NoError(t, err)
 			assert.Equal(t, tt.wantStdout, stdout.String())
 			assert.Equal(t, "", stderr.String())
 		})