Repository: cli/cli

Issue:
`gh secret` subcommands don't work anymore if called outside of Git repository
### Describe the bug

After upgrading to GitHub CLI `2.67.0` all `gh secret set` commands that are run outside of git repository fails with this message:

```
failed to run git: fatal: not a git repository (or any parent up to mount point /)
Stopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).
```

Actions like [this](https://github.com/AnimMouse/setup-rclone/blob/main/update-config/action.yaml#L20) are now failing if they are run without using `actions/checkout`.

Related: #4688 #10209

Maybe related: #10352

### Affected version

`2.67.0` `2.66.1` `2.66.0`

### Steps to reproduce the behavior

1. Upgrade to GitHub CLI 2.67.0
2. Run `GH_REPO=AnimMouse/test-repo gh secret set test_secret`
3. See error:

```
failed to run git: fatal: not a git repository (or any parent up to mount point /)
Stopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).
```

### Expected vs actual behavior

Earlier version of `gh secret` worked without checking out the repository.

```
GH_REPO=AnimMouse/test-repo gh secret set test_secret
? Paste your secret: 
```

Discussion:
@iamazeem: Verified. 👍 

`GH_REPO` is working fine up to [v2.65.0](https://github.com/cli/cli/releases/tag/v2.65.0) for `gh secret`.

The `--repo` flag is working fine.
It may be used as an alternative (or a workaround):

```shell
gh secret set test_secret --repo AnimMouse/test-repo gh
```

And, in https://github.com/AnimMouse/setup-rclone/blob/main/update-config/action.yaml:

```diff
    - name: Update Rclone config for Unix-like
      if: runner.os == 'Linux' || runner.os == 'macOS'
      shell: bash
-     run: base64 < ~/.config/rclone/rclone.conf | gh secret set "$rclone_config_secret_name"
+     run: base64 < ~/.config/rclone/rclone.conf | gh secret set "$rclone_config_secret_name" --repo '${{ github.repository }}'
      env:
        rclone_config_secret_name: ${{ inputs.rclone_config_secret_name }}
        GITHUB_TOKEN: ${{ inputs.token }}
-       GH_REPO: ${{ github.repository }}
```

Other commands such as `gh variable list`, `gh pr list`, `gh issue list`, and `gh release list` are working fine with `GH_REPO` in a non-git directory.
The only affected command seems to be `gh secret`.@iamazeem: Related to #10209:

https://github.com/cli/cli/blob/817eeb26e567de11007c8a82c25e61c7e20e4337/pkg/cmd/secret/set/set.go#L107-L118

https://github.com/cli/cli/blob/817eeb26e567de11007c8a82c25e61c7e20e4337/pkg/cmd/secret/list/list.go#L70-L82

https://github.com/cli/cli/blob/817eeb26e567de11007c8a82c25e61c7e20e4337/pkg/cmd/secret/delete/delete.go#L49-L61

Checking for `GH_REPO` in addition to `--repo` flag works fine (need to import `os` package):

```diff
-			opts.BaseRepo = f.BaseRepo
-			if !cmd.Flags().Changed("repo") {
+			if cmd.Flags().Changed("repo") || os.Getenv("GH_REPO") != "" {
+				opts.BaseRepo = f.BaseRepo
+			} else {
```

Here's the branch with this fix: https://github.com/iamazeem/cli/tree/10525-gh-secret-gh-repo-env-var

Local test runs:

```shell
# ./gh is a symlink here

$ ./gh --version 
gh version 2.66.1-227-g817eeb26 (2025-03-02)
https://github.com/cli/cli/releases/latest

$ ./gh secret list
failed to run git: fatal: not a git repository (or any of the parent directories): .git

$ GH_REPO=iamazeem/test ./gh secret set S1 --body "123"
✓ Set Actions secret S1 for iamazeem/test

$ GH_REPO=iamazeem/test ./gh secret delete S1
✓ Deleted Actions secret S1 from iamazeem/test
```@williammartin: 🤮 Sorry for the regression @AnimMouse 

I introduced this in https://github.com/cli/cli/pull/10209. This behaviour is shared across all `secret` commands.

The fundamental issue here is that the [logic we use to inject the `--repo` flag to various commands](https://github.com/cli/cli/blob/817eeb26e567de11007c8a82c25e61c7e20e4337/pkg/cmd/secret/secret.go#L24) is also [aware of `GH_REPO`](https://github.com/cli/cli/blob/817eeb26e567de11007c8a82c25e61c7e20e4337/pkg/cmdutil/repo_override.go#L61-L63) but the check `cmd.Flags().Changed("repo")` is not.

@iamazeem's (thanks) implementation is functionally correct, but I'd really like it if there were an approach that was little less coupled. I'll think about it, but we're going to release today or tomorrow so if I don't have a solution before then, I'll accept the current approach to ensure this is fixed asap.@andyfeller: ### Where is `cli` code already coupled with `GH_REPO`?

> _"... but I'd really like it if there were an approach that was little less coupled."_

Agree with @williammartin.  For perspective, here are the other places in GitHub CLI code that looks at `GH_REPO` environment variable.

I feel like we should be able to do something for `cmd`-level logic to make this cleaner. 🤔 

Source: [`org:cli "GH_REPO" -path:**/*_test.go language:Go`](https://github.com/search?q=org%3Acli+%22GH_REPO%22+-path%3A**%2F*_test.go+language%3AGo&type=code)

https://github.com/cli/cli/blob/817eeb26e567de11007c8a82c25e61c7e20e4337/pkg/cmd/browse/browse.go#L145-L148

https://github.com/cli/cli/blob/817eeb26e567de11007c8a82c25e61c7e20e4337/pkg/cmd/pr/create/create.go#L150-L155

https://github.com/cli/cli/blob/817eeb26e567de11007c8a82c25e61c7e20e4337/pkg/cmdutil/repo_override.go#L60-L70

https://github.com/cli/cli/blob/817eeb26e567de11007c8a82c25e61c7e20e4337/pkg/cmd/api/api.go#L563-L574

https://github.com/cli/go-gh/blob/13104ed7b2db4b8c1a83de4248ddfaaab7682916/pkg/repository/repository.go#L117-L125@williammartin: ## Acceptance Criteria

**Given** my cwd is not a git repo
**And** I have `GH_REPO` set to a repo I have permissions on
**When** I run any of the `gh secret` commands
**Then** I am able to interact with secrets on that repo@AnimMouse: Thank you @iamazeem, @williammartin, and @andyfeller for the speedy bug fix!

Solution patch:
diff --git a/pkg/cmd/secret/delete/delete.go b/pkg/cmd/secret/delete/delete.go
index 84a5e7a83..1cbfbca9d 100644
--- a/pkg/cmd/secret/delete/delete.go
+++ b/pkg/cmd/secret/delete/delete.go
@@ -3,6 +3,7 @@ package delete
 import (
 	"fmt"
 	"net/http"
+	"os"
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/api"
@@ -48,8 +49,9 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
 		RunE: func(cmd *cobra.Command, args []string) error {
 			// If the user specified a repo directly, then we're using the OverrideBaseRepoFunc set by EnableRepoOverride
 			// So there's no reason to use the specialised BaseRepoFunc that requires remote disambiguation.
-			opts.BaseRepo = f.BaseRepo
-			if !cmd.Flags().Changed("repo") {
+			if cmd.Flags().Changed("repo") || os.Getenv("GH_REPO") != "" {
+				opts.BaseRepo = f.BaseRepo
+			} else {
 				// If they haven't specified a repo directly, then we will wrap the BaseRepoFunc in one that errors if
 				// there might be multiple valid remotes.
 				opts.BaseRepo = shared.RequireNoAmbiguityBaseRepoFunc(opts.BaseRepo, f.Remotes)
diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go
index af3526386..c976bd60e 100644
--- a/pkg/cmd/secret/list/list.go
+++ b/pkg/cmd/secret/list/list.go
@@ -3,6 +3,7 @@ package list
 import (
 	"fmt"
 	"net/http"
+	"os"
 	"slices"
 	"strings"
 	"time"
@@ -69,8 +70,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
 		RunE: func(cmd *cobra.Command, args []string) error {
 			// If the user specified a repo directly, then we're using the OverrideBaseRepoFunc set by EnableRepoOverride
 			// So there's no reason to use the specialised BaseRepoFunc that requires remote disambiguation.
-			opts.BaseRepo = f.BaseRepo
-			if !cmd.Flags().Changed("repo") {
+			if cmd.Flags().Changed("repo") || os.Getenv("GH_REPO") != "" {
+				opts.BaseRepo = f.BaseRepo
+			} else {
 				// If they haven't specified a repo directly, then we will wrap the BaseRepoFunc in one that errors if
 				// there might be multiple valid remotes.
 				opts.BaseRepo = shared.RequireNoAmbiguityBaseRepoFunc(opts.BaseRepo, f.Remotes)
diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go
index 3c12b4192..89f11f1cf 100644
--- a/pkg/cmd/secret/set/set.go
+++ b/pkg/cmd/secret/set/set.go
@@ -106,8 +106,9 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
 		RunE: func(cmd *cobra.Command, args []string) error {
 			// If the user specified a repo directly, then we're using the OverrideBaseRepoFunc set by EnableRepoOverride
 			// So there's no reason to use the specialised BaseRepoFunc that requires remote disambiguation.
-			opts.BaseRepo = f.BaseRepo
-			if !cmd.Flags().Changed("repo") {
+			if cmd.Flags().Changed("repo") || os.Getenv("GH_REPO") != "" {
+				opts.BaseRepo = f.BaseRepo
+			} else {
 				// If they haven't specified a repo directly, then we will wrap the BaseRepoFunc in one that errors if
 				// there might be multiple valid remotes.
 				opts.BaseRepo = shared.RequireNoAmbiguityBaseRepoFunc(opts.BaseRepo, f.Remotes)



Test patch:
diff --git a/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar b/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar
index f4d1bbb4a..02dec06a0 100644
--- a/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar
+++ b/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar
@@ -34,3 +34,29 @@ stderr 'multiple remotes detected. please specify which repo to use by providing
 # Secret delete requires disambiguation
 ! exec gh secret delete 'TEST_SECRET_NAME'
 stderr 'multiple remotes detected. please specify which repo to use by providing the -R, --repo argument'
+
+# Move out of the fork repo to test whether secret commands work without local repository context
+cd ..
+
+# Secret set using --repo flag does not require disambiguation
+exec gh secret set 'TEST_SECRET_NAME' --body 'TEST_SECRET_VALUE' --repo ${ORG}/${REPO}-fork
+
+# Secret list using --repo flag does not require disambiguation
+exec gh secret list --repo ${ORG}/${REPO}-fork
+stdout 'TEST_SECRET_NAME'
+
+# Secret delete using --repo flag does not require disambiguation
+exec gh secret delete 'TEST_SECRET_NAME' --repo ${ORG}/${REPO}-fork
+
+# Setup GH_REPO for testing environment variable behavior
+env GH_REPO=${ORG}/${REPO}-fork
+
+# Secret set using GH_REPO env var does not require disambiguation
+exec gh secret set 'TEST_SECRET_NAME2' --body 'TEST_SECRET_VALUE2'
+
+# Secret list using GH_REPO env var does not require disambiguation
+exec gh secret list
+stdout 'TEST_SECRET_NAME2'
+
+# Secret delete using GH_REPO env var does not require disambiguation
+exec gh secret delete 'TEST_SECRET_NAME2'
diff --git a/pkg/cmd/secret/delete/delete_test.go b/pkg/cmd/secret/delete/delete_test.go
index 377879cf2..2f6cad431 100644
--- a/pkg/cmd/secret/delete/delete_test.go
+++ b/pkg/cmd/secret/delete/delete_test.go
@@ -144,6 +144,7 @@ func TestNewCmdDeleteBaseRepoFuncs(t *testing.T) {
 	tests := []struct {
 		name          string
 		args          string
+		env           map[string]string
 		prompterStubs func(*prompter.MockPrompter)
 		wantRepo      ghrepo.Interface
 		wantErr       error
@@ -154,14 +155,22 @@ func TestNewCmdDeleteBaseRepoFuncs(t *testing.T) {
 			wantRepo: ghrepo.New("owner", "repo"),
 		},
 		{
-			name: "when there is no repo flag provided, and no prompting, the base func requiring no ambiguity is used",
+			name: "when GH_REPO env var is provided, the factory base repo func is used",
+			args: "SECRET_NAME",
+			env: map[string]string{
+				"GH_REPO": "owner/repo",
+			},
+			wantRepo: ghrepo.New("owner", "repo"),
+		},
+		{
+			name: "when there is no repo flag or GH_REPO env var provided, and no prompting, the base func requiring no ambiguity is used",
 			args: "SECRET_NAME",
 			wantErr: shared.AmbiguousBaseRepoError{
 				Remotes: remotes,
 			},
 		},
 		{
-			name: "when there is no repo flag provided, and can prompt, the base func resolving ambiguity is used",
+			name: "when there is no repo flag or GH_REPO env var provided, and can prompt, the base func resolving ambiguity is used",
 			args: "SECRET_NAME",
 			prompterStubs: func(pm *prompter.MockPrompter) {
 				pm.RegisterSelect(
@@ -199,6 +208,10 @@ func TestNewCmdDeleteBaseRepoFuncs(t *testing.T) {
 				},
 			}
 
+			for k, v := range tt.env {
+				t.Setenv(k, v)
+			}
+
 			argv, err := shlex.Split(tt.args)
 			assert.NoError(t, err)
 
diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go
index 601de2572..f4ecde73e 100644
--- a/pkg/cmd/secret/list/list_test.go
+++ b/pkg/cmd/secret/list/list_test.go
@@ -124,6 +124,7 @@ func TestNewCmdListBaseRepoFuncs(t *testing.T) {
 	tests := []struct {
 		name          string
 		args          string
+		env           map[string]string
 		prompterStubs func(*prompter.MockPrompter)
 		wantRepo      ghrepo.Interface
 		wantErr       error
@@ -134,14 +135,21 @@ func TestNewCmdListBaseRepoFuncs(t *testing.T) {
 			wantRepo: ghrepo.New("owner", "repo"),
 		},
 		{
-			name: "when there is no repo flag provided, and no prompting, the base func requiring no ambiguity is used",
+			name: "when GH_REPO env var is provided, the factory base repo func is used",
+			env: map[string]string{
+				"GH_REPO": "owner/repo",
+			},
+			wantRepo: ghrepo.New("owner", "repo"),
+		},
+		{
+			name: "when there is no repo flag or GH_REPO env var provided, and no prompting, the base func requiring no ambiguity is used",
 			args: "",
 			wantErr: shared.AmbiguousBaseRepoError{
 				Remotes: remotes,
 			},
 		},
 		{
-			name: "when there is no repo flag provided, and can prompt, the base func resolving ambiguity is used",
+			name: "when there is no repo flag or GH_REPO env var provided, and can prompt, the base func resolving ambiguity is used",
 			args: "",
 			prompterStubs: func(pm *prompter.MockPrompter) {
 				pm.RegisterSelect(
@@ -179,6 +187,10 @@ func TestNewCmdListBaseRepoFuncs(t *testing.T) {
 				},
 			}
 
+			for k, v := range tt.env {
+				t.Setenv(k, v)
+			}
+
 			argv, err := shlex.Split(tt.args)
 			assert.NoError(t, err)
 
diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go
index 99c8c6cbe..22c7aaab8 100644
--- a/pkg/cmd/secret/set/set_test.go
+++ b/pkg/cmd/secret/set/set_test.go
@@ -243,6 +243,7 @@ func TestNewCmdSetBaseRepoFuncs(t *testing.T) {
 	tests := []struct {
 		name          string
 		args          string
+		env           map[string]string
 		prompterStubs func(*prompter.MockPrompter)
 		wantRepo      ghrepo.Interface
 		wantErr       error
@@ -253,14 +254,22 @@ func TestNewCmdSetBaseRepoFuncs(t *testing.T) {
 			wantRepo: ghrepo.New("owner", "repo"),
 		},
 		{
-			name: "when there is no repo flag provided, and no prompting, the base func requiring no ambiguity is used",
+			name: "when GH_REPO env var is provided, the factory base repo func is used",
+			args: "SECRET_NAME",
+			env: map[string]string{
+				"GH_REPO": "owner/repo",
+			},
+			wantRepo: ghrepo.New("owner", "repo"),
+		},
+		{
+			name: "when there is no repo flag or GH_REPO env var provided, and no prompting, the base func requiring no ambiguity is used",
 			args: "SECRET_NAME",
 			wantErr: shared.AmbiguousBaseRepoError{
 				Remotes: remotes,
 			},
 		},
 		{
-			name: "when there is no repo flag provided, and can prompt, the base func resolving ambiguity is used",
+			name: "when there is no repo flag or GH_REPO env var provided, and can prompt, the base func resolving ambiguity is used",
 			args: "SECRET_NAME",
 			prompterStubs: func(pm *prompter.MockPrompter) {
 				pm.RegisterSelect(
@@ -298,6 +307,10 @@ func TestNewCmdSetBaseRepoFuncs(t *testing.T) {
 				},
 			}
 
+			for k, v := range tt.env {
+				t.Setenv(k, v)
+			}
+
 			argv, err := shlex.Split(tt.args)
 			assert.NoError(t, err)