Repository: cli/cli

Issue:
gh repo command is slower in wsl2
### GitHub CLI command "gh repo create" slow to respond in wsl2 

gh version 2.24.3

### Steps to reproduce the behavior

1. Type this 'gh repo create '
2. View the output ' interactive menu do not appear (some time takes up 10 min)'

### Expected vs actual behavior
Expected behavior : interactive menu should appear as soon as command is pressed
Actual Behavior : it takes upto 10 min to open interactive menu 
While it is not working on wsl2 but it is working perfectly on windows command prompt 
### Logs

Paste the activity from your command line. Redact if needed.


Hints from issue comments:
@samcoe: @VikasPoddar Sorry you are having this trouble. We have not had reports in the past of `gh` being slow inside wsl2. Are you seeing slowness for commands that do not have interactive prompts? Did this issue start recently? @vilmibm: I use gh on wsl2 daily and can't reproduce this, FWIW. ran in both Alacritty and Windows Terminal.@vikaspoddar: @samcoe This issue started recently in wsl2 environment for interactive command and other commands like 

> gh repo list

But no problem are occurring in powershell environment.
I have reinstalled gh CLI but this condition occured again
@samcoe: @VikasPoddar As the team has not been able to reproduce this issue I am not sure there is much else we can do to help without more information to go on. The only idea I have left is to update `gh` to the latest version as there were a couple bug fixes since version 2.24.3.@vikaspoddar: @samcoe Okay sir, I will reinstall the gh CLI tool
And will inform you@mislav: By default, dbus (which gh tries to use to communicate to Secret Service on linux) is not running under WSL2, and an attempt to retrieve an encrypted token should immediately fail and the lookup should fall back to gh's config file instead.

However, for you and apparently for @mbates and some other users the operation seems to hang for a long time or indefinitely. I'm not sure what circumstances lead to this, but @vilmibm was guessing that this might be affecting users who have taken steps to explicitly enable dbus in their WSL2 instances. Does this describe you?

In any case, I'm considering adding some debugging to token lookups so that the user at least gets an indication where the gh process is stuck.@benjlevesque: @VikasPoddar maybe related to https://github.com/cli/cli/issues/7145?@vikaspoddar: @benjlevesque I will take a look at it. Thank you @linesthatinterlace: For what it's worth, I just had this issue and `wsl --shutdown` followed by `wsl` fixed it. (It worked the first time - I made an error and had to cancel the auth, then I was getting this problem the second time.)@JayJamieson: Can confirm this is slow for me too. I restarted wsl2 and first command is super fast then slows down again. 

- What information about my system will be helpful?

```
gh version 2.28.0 (2023-04-25)
https://github.com/cli/cli/releases/tag/v2.28.0

Linux DESKTOP-TLURRUM 5.15.90.1-microsoft-standard-WSL2 #1 SMP Fri Jan 27 02:56:13 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux

NAME="Ubuntu"
VERSION="20.04.5 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.5 LTS"
VERSION_ID="20.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=focal
UBUNTU_CODENAME=focal
```

I tried looking at strace output of `gh status` and found a lot of the following. Waiting to read and then timing out, my assumption is this could be the endless loop somewhere? 

```
strace -f -o /tmp/strace.log -p <PID>

6690  futex(0x2af3c78, FUTEX_WAIT_PRIVATE, 0, {tv_sec=0, tv_nsec=119772809} <unfinished ...>
6695  <... read resumed>"\0", 16)       = 1
6695  epoll_pwait(4, [], 128, 0, NULL, 3724) = 0
6695  epoll_pwait(4, [], 128, 119, NULL, 3724) = 0
6690  <... futex resumed>)              = -1 ETIMEDOUT (Connection timed out)
6695  futex(0x2aed388, FUTEX_WAKE_PRIVATE, 1 <unfinished ...>
6690  epoll_pwait(4,  <unfinished ...>
6695  <... futex resumed>)              = 1
6690  <... epoll_pwait resumed>[], 128, 0, NULL, 3724) = 0
6689  <... futex resumed>) 
```

> By default, dbus (which gh tries to use to communicate to Secret Service on linux) is not running under WSL2, and an attempt to retrieve an encrypted token should immediately fail and the lookup should fall back to gh's config file instead.
> 
> However, for you and apparently for @mbates and some other users the operation seems to hang for a long time or indefinitely. I'm not sure what circumstances lead to this, but @vilmibm was guessing that this might be affecting users who have taken steps to explicitly enable dbus in their WSL2 instances. Does this describe you?
> 
> In any case, I'm considering adding some debugging to token lookups so that the user at least gets an indication where the gh process is stuck.

Im not a strace exprt but i see a lot of dbus output running `sudo strace -p <PID> -s 512 -ewrite 2> /tmp/strace.log` and no further progress. I had this running close to 10 minutes with continuous dbus calls and no progress.
@samcoe: I think there is enough evidence here that this is related to `dbus` and encrypted token storage. Right now we are waiting for the `dbus` process to timeout when we should probably have our own timeout in `gh`. @mislav what do you think about adding a timeout to the retrieval of encrypted tokens?@Shahaed: > I think there is enough evidence here that this is related to `dbus` and encrypted token storage. Right now we are waiting for the `dbus` process to timeout when we should probably have our own timeout in `gh`. @mislav what do you think about adding a timeout to the retrieval of encrypted tokens?

@samcoe  Can (probably) confirm this is correct since the workaround here: https://github.com/cli/cli/issues/7145 fixes the issue. @samcoe: Is anyone still experiencing this issue with the latest release v2.30.0? We bumped the library we use for accessing secure token storage and it had a couple of bug fixes in it.@RobertGemmaJr: @samcoe Can confirm this is still an issue with WSL2 (Ubuntu) for me. Followed the [linux install instructions](https://github.com/cli/cli/blob/trunk/docs/install_linux.md) and `gh auth login` is hanging indefinitely.@Arinerron: I'm having this issue on Arch Linux. dbus is running:
```
● dbus.service - D-Bus System Message Bus
     Loaded: loaded (/usr/lib/systemd/system/dbus.service; static)
     Active: active (running) since Tue 2023-06-06 16:15:51 PDT; 5 days ago
```

It's also hanging here:
```
futex(0x55a453487368, FUTEX_WAIT_PRIVATE, 0, NULL
```@simenandre: Did we ever create an issue for this in https://github.com/zalando/go-keyring?

Solution patch:
diff --git a/cmd/gh/main.go b/cmd/gh/main.go
index c36a4e6e6..15af90721 100644
--- a/cmd/gh/main.go
+++ b/cmd/gh/main.go
@@ -109,7 +109,7 @@ func mainRun() exitCode {
 	if cmd, err := rootCmd.ExecuteContextC(ctx); err != nil {
 		var pagerPipeError *iostreams.ErrClosedPagerPipe
 		var noResultsError cmdutil.NoResultsError
-		var execError *exec.ExitError
+		var extError *root.ExternalCommandExitError
 		var authError *root.AuthError
 		if err == cmdutil.SilentError {
 			return exitError
@@ -130,8 +130,9 @@ func mainRun() exitCode {
 			}
 			// no results is not a command failure
 			return exitOK
-		} else if errors.As(err, &execError) {
-			return exitCode(execError.ExitCode())
+		} else if errors.As(err, &extError) {
+			// pass on exit codes from extensions and shell aliases
+			return exitCode(extError.ExitCode())
 		}
 
 		printError(stderr, err, cmd, hasDebug)
diff --git a/internal/config/config.go b/internal/config/config.go
index 02756dad5..bf14a1aa0 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -4,9 +4,9 @@ import (
 	"os"
 	"path/filepath"
 
+	"github.com/cli/cli/v2/internal/keyring"
 	ghAuth "github.com/cli/go-gh/v2/pkg/auth"
 	ghConfig "github.com/cli/go-gh/v2/pkg/config"
-	"github.com/zalando/go-keyring"
 )
 
 const (
diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go
new file mode 100644
index 000000000..afb5025e6
--- /dev/null
+++ b/internal/keyring/keyring.go
@@ -0,0 +1,68 @@
+// Package keyring is a simple wrapper that adds timeouts to the zalando/go-keyring package.
+package keyring
+
+import (
+	"time"
+
+	"github.com/zalando/go-keyring"
+)
+
+type TimeoutError struct {
+	message string
+}
+
+func (e *TimeoutError) Error() string {
+	return e.message
+}
+
+// Set secret in keyring for user.
+func Set(service, user, secret string) error {
+	ch := make(chan error, 1)
+	go func() {
+		defer close(ch)
+		ch <- keyring.Set(service, user, secret)
+	}()
+	select {
+	case err := <-ch:
+		return err
+	case <-time.After(3 * time.Second):
+		return &TimeoutError{"timeout while trying to set secret in keyring"}
+	}
+}
+
+// Get secret from keyring given service and user name.
+func Get(service, user string) (string, error) {
+	ch := make(chan struct {
+		val string
+		err error
+	}, 1)
+	go func() {
+		defer close(ch)
+		val, err := keyring.Get(service, user)
+		ch <- struct {
+			val string
+			err error
+		}{val, err}
+	}()
+	select {
+	case res := <-ch:
+		return res.val, res.err
+	case <-time.After(3 * time.Second):
+		return "", &TimeoutError{"timeout while trying to get secret from keyring"}
+	}
+}
+
+// Delete secret from keyring.
+func Delete(service, user string) error {
+	ch := make(chan error, 1)
+	go func() {
+		defer close(ch)
+		ch <- keyring.Delete(service, user)
+	}()
+	select {
+	case err := <-ch:
+		return err
+	case <-time.After(3 * time.Second):
+		return &TimeoutError{"timeout while trying to delete secret from keyring"}
+	}
+}
diff --git a/pkg/cmd/alias/imports/import.go b/pkg/cmd/alias/imports/import.go
index f7de0bb78..ef2176d45 100644
--- a/pkg/cmd/alias/imports/import.go
+++ b/pkg/cmd/alias/imports/import.go
@@ -122,15 +122,31 @@ func importRun(opts *ImportOptions) error {
 	var msg strings.Builder
 
 	for _, alias := range getSortedKeys(aliasMap) {
+		var existingAlias bool
+		if _, err := aliasCfg.Get(alias); err == nil {
+			existingAlias = true
+		}
+
 		if !opts.validAliasName(alias) {
-			msg.WriteString(
-				fmt.Sprintf("%s Could not import alias %s: already a gh command, extension, or alias\n",
-					cs.FailureIcon(),
-					cs.Bold(alias),
-				),
-			)
+			if !existingAlias {
+				msg.WriteString(
+					fmt.Sprintf("%s Could not import alias %s: already a gh command or extension\n",
+						cs.FailureIcon(),
+						cs.Bold(alias),
+					),
+				)
+				continue
+			}
 
-			continue
+			if existingAlias && !opts.OverwriteExisting {
+				msg.WriteString(
+					fmt.Sprintf("%s Could not import alias %s: name already taken\n",
+						cs.FailureIcon(),
+						cs.Bold(alias),
+					),
+				)
+				continue
+			}
 		}
 
 		expansion := aliasMap[alias]
@@ -142,31 +158,19 @@ func importRun(opts *ImportOptions) error {
 					cs.Bold(alias),
 				),
 			)
-
 			continue
 		}
 
-		if _, err := aliasCfg.Get(alias); err == nil {
-			if opts.OverwriteExisting {
-				aliasCfg.Add(alias, expansion)
+		aliasCfg.Add(alias, expansion)
 
-				msg.WriteString(
-					fmt.Sprintf("%s Changed alias %s\n",
-						cs.WarningIcon(),
-						cs.Bold(alias),
-					),
-				)
-			} else {
-				msg.WriteString(
-					fmt.Sprintf("%s Could not import alias %s: name already taken\n",
-						cs.FailureIcon(),
-						cs.Bold(alias),
-					),
-				)
-			}
+		if existingAlias && opts.OverwriteExisting {
+			msg.WriteString(
+				fmt.Sprintf("%s Changed alias %s\n",
+					cs.WarningIcon(),
+					cs.Bold(alias),
+				),
+			)
 		} else {
-			aliasCfg.Add(alias, expansion)
-
 			msg.WriteString(
 				fmt.Sprintf("%s Added alias %s\n",
 					cs.SuccessIcon(),
diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go
index 8070fe9ae..77a5f125c 100644
--- a/pkg/cmd/auth/status/status.go
+++ b/pkg/cmd/auth/status/status.go
@@ -66,7 +66,7 @@ func statusRun(opts *StatusOptions) error {
 	// TODO check tty
 
 	stderr := opts.IO.ErrOut
-
+	stdout := opts.IO.Out
 	cs := opts.IO.ColorScheme()
 
 	statusInfo := map[string][]string{}
@@ -166,13 +166,22 @@ func statusRun(opts *StatusOptions) error {
 		if !ok {
 			continue
 		}
-		if prevEntry {
+		if prevEntry && failed {
 			fmt.Fprint(stderr, "\n")
+		} else if prevEntry && !failed {
+			fmt.Fprint(stdout, "\n")
 		}
 		prevEntry = true
-		fmt.Fprintf(stderr, "%s\n", cs.Bold(hostname))
-		for _, line := range lines {
-			fmt.Fprintf(stderr, "  %s\n", line)
+		if failed {
+			fmt.Fprintf(stderr, "%s\n", cs.Bold(hostname))
+			for _, line := range lines {
+				fmt.Fprintf(stderr, "  %s\n", line)
+			}
+		} else {
+			fmt.Fprintf(stdout, "%s\n", cs.Bold(hostname))
+			for _, line := range lines {
+				fmt.Fprintf(stdout, "  %s\n", line)
+			}
 		}
 	}
 
diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go
index 42664d77c..e4240a5b5 100644
--- a/pkg/cmd/browse/browse.go
+++ b/pkg/cmd/browse/browse.go
@@ -163,14 +163,12 @@ func runBrowse(opts *BrowseOptions) error {
 		return fmt.Errorf("unable to determine base repository: %w", err)
 	}
 
-	if opts.Commit != "" {
-		if opts.Commit == emptyCommitFlag {
-			commit, err := opts.GitClient.LastCommit()
-			if err != nil {
-				return err
-			}
-			opts.Commit = commit.Sha
+	if opts.Commit != "" && opts.Commit == emptyCommitFlag {
+		commit, err := opts.GitClient.LastCommit()
+		if err != nil {
+			return err
 		}
+		opts.Commit = commit.Sha
 	}
 
 	section, err := parseSection(baseRepo, opts)
diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go
index 2414c1807..a4db9d3a1 100644
--- a/pkg/cmd/issue/create/create.go
+++ b/pkg/cmd/issue/create/create.go
@@ -11,7 +11,6 @@ import (
 	"github.com/cli/cli/v2/internal/config"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/internal/text"
-	"github.com/cli/cli/v2/pkg/cmd/pr/shared"
 	prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -143,7 +142,7 @@ func createRun(opts *CreateOptions) (err error) {
 		milestones = []string{opts.Milestone}
 	}
 
-	meReplacer := shared.NewMeReplacer(apiClient, baseRepo.RepoHost())
+	meReplacer := prShared.NewMeReplacer(apiClient, baseRepo.RepoHost())
 	assignees, err := meReplacer.ReplaceSlice(opts.Assignees)
 	if err != nil {
 		return err
@@ -167,7 +166,7 @@ func createRun(opts *CreateOptions) (err error) {
 		}
 	}
 
-	tpl := shared.NewTemplateManager(httpClient, baseRepo, opts.Prompter, opts.RootDirOverride, !opts.HasRepoOverride, false)
+	tpl := prShared.NewTemplateManager(httpClient, baseRepo, opts.Prompter, opts.RootDirOverride, !opts.HasRepoOverride, false)
 
 	if opts.WebMode {
 		var openURL string
@@ -222,7 +221,7 @@ func createRun(opts *CreateOptions) (err error) {
 			templateContent := ""
 
 			if opts.RecoverFile == "" {
-				var template shared.Template
+				var template prShared.Template
 
 				if opts.Template != "" {
 					template, err = tpl.Select(opts.Template)
@@ -325,7 +324,7 @@ func createRun(opts *CreateOptions) (err error) {
 	return
 }
 
-func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb shared.IssueMetadataState) (string, error) {
+func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prShared.IssueMetadataState) (string, error) {
 	openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
 	return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb)
 }
diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go
index 71ed5ed0f..20e77e141 100644
--- a/pkg/cmd/issue/list/list.go
+++ b/pkg/cmd/issue/list/list.go
@@ -15,7 +15,6 @@ import (
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/internal/text"
 	issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared"
-	"github.com/cli/cli/v2/pkg/cmd/pr/shared"
 	prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
@@ -133,7 +132,7 @@ func listRun(opts *ListOptions) error {
 	}
 
 	issueState := strings.ToLower(opts.State)
-	if issueState == "open" && shared.QueryHasStateClause(opts.Search) {
+	if issueState == "open" && prShared.QueryHasStateClause(opts.Search) {
 		issueState = ""
 	}
 
@@ -228,7 +227,7 @@ func issueList(client *http.Client, repo ghrepo.Interface, filters prShared.Filt
 	}
 
 	var err error
-	meReplacer := shared.NewMeReplacer(apiClient, repo.RepoHost())
+	meReplacer := prShared.NewMeReplacer(apiClient, repo.RepoHost())
 	filters.Assignee, err = meReplacer.Replace(filters.Assignee)
 	if err != nil {
 		return nil, err
diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json b/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json
index 9bce5f745..186fa1bab 100644
--- a/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json
+++ b/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json
@@ -25,19 +25,19 @@
         "labels": {
           "nodes": [
             {
-              "name": "one"
+              "name": "Closed: Won't Fix"
             },
             {
-              "name": "two"
+              "name": "Status: In Progress"
             },
             {
-              "name": "three"
+              "name": "Type: Bug"
             },
             {
-              "name": "four"
+              "name": "help wanted"
             },
             {
-              "name": "five"
+              "name": "Closed: Duplicate"
             }
           ],
           "totalCount": 5
diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go
index b721451fa..83132291f 100644
--- a/pkg/cmd/issue/view/view.go
+++ b/pkg/cmd/issue/view/view.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"sort"
 	"strings"
 	"time"
 
@@ -306,6 +307,11 @@ func issueLabelList(issue *api.Issue, cs *iostreams.ColorScheme) string {
 		return ""
 	}
 
+	// ignore case sort
+	sort.SliceStable(issue.Labels.Nodes, func(i, j int) bool {
+		return strings.ToLower(issue.Labels.Nodes[i].Name) < strings.ToLower(issue.Labels.Nodes[j].Name)
+	})
+
 	labelNames := make([]string, len(issue.Labels.Nodes))
 	for i, label := range issue.Labels.Nodes {
 		if cs == nil {
diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json
index 00f768f17..be4a7713d 100644
--- a/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json
+++ b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json
@@ -37,19 +37,19 @@
         "labels": {
           "nodes": [
             {
-              "name": "one"
+              "name": "Closed: Won't Fix"
             },
             {
-              "name": "two"
+              "name": "Status: In Progress"
             },
             {
-              "name": "three"
+              "name": "Type: Bug"
             },
             {
-              "name": "four"
+              "name": "help wanted"
             },
             {
-              "name": "five"
+              "name": "Closed: Duplicate"
             }
           ],
           "totalcount": 5
diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go
index 71bda0649..4a69de2f4 100644
--- a/pkg/cmd/pr/view/view.go
+++ b/pkg/cmd/pr/view/view.go
@@ -415,6 +415,11 @@ func prLabelList(pr api.PullRequest, cs *iostreams.ColorScheme) string {
 		return ""
 	}
 
+	// ignore case sort
+	sort.SliceStable(pr.Labels.Nodes, func(i, j int) bool {
+		return strings.ToLower(pr.Labels.Nodes[i].Name) < strings.ToLower(pr.Labels.Nodes[j].Name)
+	})
+
 	labelNames := make([]string, 0, len(pr.Labels.Nodes))
 	for _, label := range pr.Labels.Nodes {
 		labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name))
diff --git a/pkg/cmd/project/close/close.go b/pkg/cmd/project/close/close.go
index 7f8006b6f..f4016fe50 100644
--- a/pkg/cmd/project/close/close.go
+++ b/pkg/cmd/project/close/close.go
@@ -128,13 +128,8 @@ func printResults(config closeConfig, project queries.Project) error {
 	if !config.io.IsStdoutTTY() {
 		return nil
 	}
-	var action string
-	if config.opts.reopen {
-		action = "Reopened"
-	} else {
-		action = "Closed"
-	}
-	_, err := fmt.Fprintf(config.io.Out, "%s project %s\n", action, project.URL)
+
+	_, err := fmt.Fprintf(config.io.Out, "%s\n", project.URL)
 	return err
 }
 
diff --git a/pkg/cmd/project/copy/copy.go b/pkg/cmd/project/copy/copy.go
index 6dae73e40..d3e07424b 100644
--- a/pkg/cmd/project/copy/copy.go
+++ b/pkg/cmd/project/copy/copy.go
@@ -139,7 +139,7 @@ func printResults(config copyConfig, project queries.Project) error {
 	if !config.io.IsStdoutTTY() {
 		return nil
 	}
-	_, err := fmt.Fprintf(config.io.Out, "Copied project to %s\n", project.URL)
+	_, err := fmt.Fprintf(config.io.Out, "%s\n", project.URL)
 	return err
 }
 
diff --git a/pkg/cmd/project/create/create.go b/pkg/cmd/project/create/create.go
index 0ecd4f5f3..894a6e9ce 100644
--- a/pkg/cmd/project/create/create.go
+++ b/pkg/cmd/project/create/create.go
@@ -110,7 +110,7 @@ func printResults(config createConfig, project queries.Project) error {
 		return nil
 	}
 
-	_, err := fmt.Fprintf(config.io.Out, "Created project '%s'\n%s\n", project.Title, project.URL)
+	_, err := fmt.Fprintf(config.io.Out, "%s\n", project.URL)
 	return err
 }
 
diff --git a/pkg/cmd/project/delete/delete.go b/pkg/cmd/project/delete/delete.go
index 0ae93277b..674c30a97 100644
--- a/pkg/cmd/project/delete/delete.go
+++ b/pkg/cmd/project/delete/delete.go
@@ -100,7 +100,7 @@ func runDelete(config deleteConfig) error {
 		return printJSON(config, *project)
 	}
 
-	return printResults(config)
+	return printResults(config, query.DeleteProject.Project)
 
 }
 
@@ -116,12 +116,12 @@ func deleteItemArgs(config deleteConfig) (*deleteProjectMutation, map[string]int
 	}
 }
 
-func printResults(config deleteConfig) error {
+func printResults(config deleteConfig, project queries.Project) error {
 	if !config.io.IsStdoutTTY() {
 		return nil
 	}
 
-	_, err := fmt.Fprintf(config.io.Out, "Deleted project\n")
+	_, err := fmt.Fprintf(config.io.Out, "Deleted project %d\n", project.Number)
 	return err
 }
 
diff --git a/pkg/cmd/project/edit/edit.go b/pkg/cmd/project/edit/edit.go
index 64ee74d78..92fee3284 100644
--- a/pkg/cmd/project/edit/edit.go
+++ b/pkg/cmd/project/edit/edit.go
@@ -150,7 +150,7 @@ func printResults(config editConfig, project queries.Project) error {
 		return nil
 	}
 
-	_, err := fmt.Fprintf(config.io.Out, "Updated project %s\n", project.URL)
+	_, err := fmt.Fprintf(config.io.Out, "%s\n", project.URL)
 	return err
 }
 
diff --git a/pkg/cmd/root/alias.go b/pkg/cmd/root/alias.go
index ba0bfe9e2..4f504f2b8 100644
--- a/pkg/cmd/root/alias.go
+++ b/pkg/cmd/root/alias.go
@@ -31,6 +31,10 @@ func NewCmdShellAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *co
 			externalCmd.Stdin = io.In
 			preparedCmd := run.PrepareCmd(externalCmd)
 			if err = preparedCmd.Run(); err != nil {
+				var execError *exec.ExitError
+				if errors.As(err, &execError) {
+					return &ExternalCommandExitError{execError}
+				}
 				return fmt.Errorf("failed to run external command: %w\n", err)
 			}
 			return nil
diff --git a/pkg/cmd/root/extension.go b/pkg/cmd/root/extension.go
index dea637619..d6d495103 100644
--- a/pkg/cmd/root/extension.go
+++ b/pkg/cmd/root/extension.go
@@ -1,13 +1,19 @@
 package root
 
 import (
+	"errors"
 	"fmt"
+	"os/exec"
 
 	"github.com/cli/cli/v2/pkg/extensions"
 	"github.com/cli/cli/v2/pkg/iostreams"
 	"github.com/spf13/cobra"
 )
 
+type ExternalCommandExitError struct {
+	*exec.ExitError
+}
+
 func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ext extensions.Extension) *cobra.Command {
 	return &cobra.Command{
 		Use:   ext.Name(),
@@ -15,6 +21,10 @@ func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ex
 		RunE: func(c *cobra.Command, args []string) error {
 			args = append([]string{ext.Name()}, args...)
 			if _, err := em.Dispatch(args, io.In, io.Out, io.ErrOut); err != nil {
+				var execError *exec.ExitError
+				if errors.As(err, &execError) {
+					return &ExternalCommandExitError{execError}
+				}
 				return fmt.Errorf("failed to run extension: %w\n", err)
 			}
 			return nil



Test patch:
diff --git a/pkg/cmd/alias/imports/import_test.go b/pkg/cmd/alias/imports/import_test.go
index 0013429f6..024f52f02 100644
--- a/pkg/cmd/alias/imports/import_test.go
+++ b/pkg/cmd/alias/imports/import_test.go
@@ -112,13 +112,14 @@ func TestImportRun(t *testing.T) {
 	importStdinMsg := "- Importing aliases from standard input"
 
 	tests := []struct {
-		name         string
-		opts         *ImportOptions
-		stdin        string
-		fileContents string
-		initConfig   string
-		wantConfig   string
-		wantStderr   string
+		name          string
+		opts          *ImportOptions
+		stdin         string
+		fileContents  string
+		initConfig    string
+		aliasCommands []*cobra.Command
+		wantConfig    string
+		wantStderr    string
 	}{
 		{
 			name: "with no existing aliases",
@@ -158,6 +159,9 @@ func TestImportRun(t *testing.T) {
                     igrep: '!gh issue list --label="$1" | grep "$2"'
                 editor: vim
             `),
+			aliasCommands: []*cobra.Command{
+				{Use: "igrep"},
+			},
 			wantConfig: heredoc.Doc(`
                 aliases:
                     igrep: '!gh issue list --label="$1" | grep "$2"'
@@ -211,6 +215,9 @@ func TestImportRun(t *testing.T) {
                     co: pr checkout
                 editor: vim
             `),
+			aliasCommands: []*cobra.Command{
+				{Use: "co"},
+			},
 			wantConfig: heredoc.Doc(`
                 aliases:
                     co: pr checkout
@@ -234,6 +241,9 @@ func TestImportRun(t *testing.T) {
                     co: pr checkout
                 editor: vim
             `),
+			aliasCommands: []*cobra.Command{
+				{Use: "co"},
+			},
 			wantConfig: heredoc.Doc(`
                 aliases:
                     co: pr checkout -R cool/repo
@@ -256,9 +266,9 @@ func TestImportRun(t *testing.T) {
 			wantStderr: strings.Join(
 				[]string{
 					importFileMsg,
-					"X Could not import alias api: already a gh command, extension, or alias",
-					"X Could not import alias issue: already a gh command, extension, or alias",
-					"X Could not import alias pr: already a gh command, extension, or alias\n\n",
+					"X Could not import alias api: already a gh command or extension",
+					"X Could not import alias issue: already a gh command or extension",
+					"X Could not import alias pr: already a gh command or extension\n\n",
 				},
 				"\n",
 			),
@@ -315,6 +325,9 @@ func TestImportRun(t *testing.T) {
 			apiCmd := &cobra.Command{Use: "api"}
 			apiCmd.AddCommand(&cobra.Command{Use: "graphql"})
 			rootCmd.AddCommand(apiCmd)
+			for _, cmd := range tt.aliasCommands {
+				rootCmd.AddCommand(cmd)
+			}
 
 			tt.opts.validAliasName = shared.ValidAliasNameFunc(rootCmd)
 			tt.opts.validAliasExpansion = shared.ValidAliasExpansionFunc(rootCmd)
diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go
index e169d1b3f..e18604202 100644
--- a/pkg/cmd/auth/status/status_test.go
+++ b/pkg/cmd/auth/status/status_test.go
@@ -76,12 +76,13 @@ func Test_statusRun(t *testing.T) {
 	readConfigs := config.StubWriteConfig(t)
 
 	tests := []struct {
-		name      string
-		opts      *StatusOptions
-		httpStubs func(*httpmock.Registry)
-		cfgStubs  func(*config.ConfigMock)
-		wantErr   string
-		wantOut   string
+		name       string
+		opts       *StatusOptions
+		httpStubs  func(*httpmock.Registry)
+		cfgStubs   func(*config.ConfigMock)
+		wantErr    string
+		wantOut    string
+		wantErrOut string
 	}{
 		{
 			name: "hostname set",
@@ -126,7 +127,7 @@ func Test_statusRun(t *testing.T) {
 					httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
 			},
 			wantErr: "SilentError",
-			wantOut: heredoc.Doc(`
+			wantErrOut: heredoc.Doc(`
 				joel.miller
 				  X joel.miller: the token in GH_CONFIG_DIR/hosts.yml is missing required scope 'read:org'
 				  - To request missing scopes, run: gh auth refresh -h joel.miller
@@ -156,7 +157,7 @@ func Test_statusRun(t *testing.T) {
 					httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
 			},
 			wantErr: "SilentError",
-			wantOut: heredoc.Doc(`
+			wantErrOut: heredoc.Doc(`
 				joel.miller
 				  X joel.miller: authentication failed
 				  - The joel.miller token in GH_CONFIG_DIR/hosts.yml is no longer valid.
@@ -298,9 +299,9 @@ func Test_statusRun(t *testing.T) {
 			cfgStubs: func(c *config.ConfigMock) {
 				c.Set("github.com", "oauth_token", "abc123")
 			},
-			httpStubs: func(reg *httpmock.Registry) {},
-			wantErr:   "SilentError",
-			wantOut:   "Hostname \"github.example.com\" not found among authenticated GitHub hosts\n",
+			httpStubs:  func(reg *httpmock.Registry) {},
+			wantErr:    "SilentError",
+			wantErrOut: "Hostname \"github.example.com\" not found among authenticated GitHub hosts\n",
 		},
 	}
 
@@ -310,13 +311,12 @@ func Test_statusRun(t *testing.T) {
 				tt.opts = &StatusOptions{}
 			}
 
-			ios, _, _, stderr := iostreams.Test()
+			ios, _, stdout, stderr := iostreams.Test()
 
 			ios.SetStdinTTY(true)
 			ios.SetStderrTTY(true)
 			ios.SetStdoutTTY(true)
 			tt.opts.IO = ios
-
 			cfg := config.NewFromString("")
 			if tt.cfgStubs != nil {
 				tt.cfgStubs(cfg)
@@ -340,8 +340,10 @@ func Test_statusRun(t *testing.T) {
 			} else {
 				assert.NoError(t, err)
 			}
+			output := strings.ReplaceAll(stdout.String(), config.ConfigDir()+string(filepath.Separator), "GH_CONFIG_DIR/")
+			errorOutput := strings.ReplaceAll(stderr.String(), config.ConfigDir()+string(filepath.Separator), "GH_CONFIG_DIR/")
 
-			output := strings.ReplaceAll(stderr.String(), config.ConfigDir()+string(filepath.Separator), "GH_CONFIG_DIR/")
+			assert.Equal(t, tt.wantErrOut, errorOutput)
 			assert.Equal(t, tt.wantOut, output)
 
 			mainBuf := bytes.Buffer{}
diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go
index ff26f4bc3..2c4e7934e 100644
--- a/pkg/cmd/issue/view/view_test.go
+++ b/pkg/cmd/issue/view/view_test.go
@@ -125,7 +125,7 @@ func TestIssueView_nontty_Preview(t *testing.T) {
 				`author:\tmarseilles`,
 				`state:\tOPEN`,
 				`comments:\t9`,
-				`labels:\tone, two, three, four, five`,
+				`labels:\tClosed: Duplicate, Closed: Won't Fix, help wanted, Status: In Progress, Type: Bug`,
 				`projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
 				`milestone:\tuluru\n`,
 				`number:\t123\n`,
@@ -196,7 +196,7 @@ func TestIssueView_tty_Preview(t *testing.T) {
 				`Open.*marseilles opened about 9 years ago.*9 comments`,
 				`8 \x{1f615} • 7 \x{1f440} • 6 \x{2764}\x{fe0f} • 5 \x{1f389} • 4 \x{1f604} • 3 \x{1f680} • 2 \x{1f44e} • 1 \x{1f44d}`,
 				`Assignees:.*marseilles, monaco\n`,
-				`Labels:.*one, two, three, four, five\n`,
+				`Labels:.*Closed: Duplicate, Closed: Won't Fix, help wanted, Status: In Progress, Type: Bug\n`,
 				`Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
 				`Milestone:.*uluru\n`,
 				`bold story`,
diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go
index 91b77cac8..5e2c4b922 100644
--- a/pkg/cmd/pr/view/view_test.go
+++ b/pkg/cmd/pr/view/view_test.go
@@ -229,7 +229,7 @@ func TestPRView_Preview_nontty(t *testing.T) {
 				`title:\tBlueberries are from a fork\n`,
 				`reviewers:\t1 \(Requested\)\n`,
 				`assignees:\tmarseilles, monaco\n`,
-				`labels:\tone, two, three, four, five\n`,
+				`labels:\tClosed: Duplicate, Closed: Won't Fix, help wanted, Status: In Progress, Type: Bug\n`,
 				`projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
 				`milestone:\tuluru\n`,
 				`\*\*blueberries taste good\*\*`,
@@ -390,7 +390,7 @@ func TestPRView_Preview(t *testing.T) {
 				`.+100.-10`,
 				`Reviewers:.*1 \(.*Requested.*\)\n`,
 				`Assignees:.*marseilles, monaco\n`,
-				`Labels:.*one, two, three, four, five\n`,
+				`Labels:.*Closed: Duplicate, Closed: Won't Fix, help wanted, Status: In Progress, Type: Bug\n`,
 				`Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
 				`Milestone:.*uluru\n`,
 				`blueberries taste good`,
diff --git a/pkg/cmd/project/close/close_test.go b/pkg/cmd/project/close/close_test.go
index 3db3c3c68..a19dcdabd 100644
--- a/pkg/cmd/project/close/close_test.go
+++ b/pkg/cmd/project/close/close_test.go
@@ -183,7 +183,7 @@ func TestRunClose_User(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(
 		t,
-		"Closed project http://a-url.com\n",
+		"http://a-url.com\n",
 		stdout.String())
 }
 
@@ -452,6 +452,6 @@ func TestRunClose_Reopen(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(
 		t,
-		"Reopened project http://a-url.com\n",
+		"http://a-url.com\n",
 		stdout.String())
 }
diff --git a/pkg/cmd/project/copy/copy_test.go b/pkg/cmd/project/copy/copy_test.go
index d731adaff..d8abf5ec9 100644
--- a/pkg/cmd/project/copy/copy_test.go
+++ b/pkg/cmd/project/copy/copy_test.go
@@ -457,6 +457,6 @@ func TestRunCopy_Me(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(
 		t,
-		"Copied project to http://a-url.com\n",
+		"http://a-url.com\n",
 		stdout.String())
 }
diff --git a/pkg/cmd/project/create/create_test.go b/pkg/cmd/project/create/create_test.go
index 0a6cc9700..3ef773cb9 100644
--- a/pkg/cmd/project/create/create_test.go
+++ b/pkg/cmd/project/create/create_test.go
@@ -146,7 +146,7 @@ func TestRunCreate_User(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(
 		t,
-		"Created project 'a title'\nhttp://a-url.com\n",
+		"http://a-url.com\n",
 		stdout.String())
 }
 
@@ -214,7 +214,7 @@ func TestRunCreate_Org(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(
 		t,
-		"Created project 'a title'\nhttp://a-url.com\n",
+		"http://a-url.com\n",
 		stdout.String())
 }
 
@@ -273,6 +273,6 @@ func TestRunCreate_Me(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(
 		t,
-		"Created project 'a title'\nhttp://a-url.com\n",
+		"http://a-url.com\n",
 		stdout.String())
 }
diff --git a/pkg/cmd/project/delete/delete_test.go b/pkg/cmd/project/delete/delete_test.go
index 28f30ebc6..a27180266 100644
--- a/pkg/cmd/project/delete/delete_test.go
+++ b/pkg/cmd/project/delete/delete_test.go
@@ -148,7 +148,8 @@ func TestRunDelete_User(t *testing.T) {
 			"data": map[string]interface{}{
 				"deleteProjectV2": map[string]interface{}{
 					"projectV2": map[string]interface{}{
-						"id": "project ID",
+						"id":     "project ID",
+						"number": 1,
 					},
 				},
 			},
@@ -171,7 +172,7 @@ func TestRunDelete_User(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(
 		t,
-		"Deleted project\n",
+		"Deleted project 1\n",
 		stdout.String())
 }
 
@@ -239,7 +240,8 @@ func TestRunDelete_Org(t *testing.T) {
 			"data": map[string]interface{}{
 				"deleteProjectV2": map[string]interface{}{
 					"projectV2": map[string]interface{}{
-						"id": "project ID",
+						"id":     "project ID",
+						"number": 1,
 					},
 				},
 			},
@@ -262,7 +264,7 @@ func TestRunDelete_Org(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(
 		t,
-		"Deleted project\n",
+		"Deleted project 1\n",
 		stdout.String())
 }
 
@@ -320,7 +322,8 @@ func TestRunDelete_Me(t *testing.T) {
 			"data": map[string]interface{}{
 				"deleteProjectV2": map[string]interface{}{
 					"projectV2": map[string]interface{}{
-						"id": "project ID",
+						"id":     "project ID",
+						"number": 1,
 					},
 				},
 			},
@@ -343,6 +346,6 @@ func TestRunDelete_Me(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(
 		t,
-		"Deleted project\n",
+		"Deleted project 1\n",
 		stdout.String())
 }
diff --git a/pkg/cmd/project/edit/edit_test.go b/pkg/cmd/project/edit/edit_test.go
index 45aff1390..605c5f5cc 100644
--- a/pkg/cmd/project/edit/edit_test.go
+++ b/pkg/cmd/project/edit/edit_test.go
@@ -226,7 +226,7 @@ func TestRunUpdate_User(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(
 		t,
-		"Updated project http://a-url.com\n",
+		"http://a-url.com\n",
 		stdout.String())
 }
 
@@ -324,7 +324,7 @@ func TestRunUpdate_Org(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(
 		t,
-		"Updated project http://a-url.com\n",
+		"http://a-url.com\n",
 		stdout.String())
 }
 
@@ -412,7 +412,7 @@ func TestRunUpdate_Me(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(
 		t,
-		"Updated project http://a-url.com\n",
+		"http://a-url.com\n",
 		stdout.String())
 }
 
@@ -507,6 +507,6 @@ func TestRunUpdate_OmitParams(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(
 		t,
-		"Updated project http://a-url.com\n",
+		"http://a-url.com\n",
 		stdout.String())
 }