Repository: cli/cli (go)

Issue:
gh release create wildcard '*' not working on windows
### Describe the bug
gh --version
gh version 2.4.0 (2021-12-21)

As the title states, I can't seem to find a way to submit multiple files with a wildcard.  Straight from the docs, this sample command fails:
gh release create v1.2.3 ./dist/*.tgz

with the following message:
CreateFile ./dist/*.tgz: The filename, directory name, or volume label syntax is incorrect.

If I run the command, with an individual file, it works such as:
gh release create v1.2.3 dist\Tool-Linux.tgz

It works.

If I open up a Linux terminal, such as git bash, and try the command in there, it works.

### Steps to reproduce the behavior

1. Type this 
dir /b dist
2. View the output 
Tool-Linux.tgz
Tool-MacOS.tgz
Tool-Windows.tgz

1. Type this 
gh release create v1.2.3 ./dist/*.tgz

2. View the output 
3. See error
CreateFile ./dist/*.tgz: The filename, directory name, or volume label syntax is incorrect.

I also tried:
gh release create v1.2.3 .\dist\*.tgz
gh release create v1.2.3 dist\*.tgz

cd dist
gh release create v1.2.3 *.tgz

with no succcess

### Expected vs actual behavior
A successfully submited release tag


Pull request:
[gh release create/upload] Expand glob patterns for all platforms
Fixes #5099.

Verified that this issue is **only applicable to Windows Command Prompt and Powershell**.
See this relevant [SO thread](https://stackoverflow.com/questions/72434739/globbing-patterns-in-windows-command-prompt-powershell) for more details.

GitBash behaves same as a Linux shell.

This issue affects `gh release create` and `gh release upload` commands.
In this PR, the glob patterns are expanded internally for Windows.

### Tests (same for Windows Command Prompt and Powershell)

```powershell
# Create test files e.g. test.{1,2,3} and log.{1,2,3}
> echo "test.1" > test.1
> echo "test.2" > test.2
> echo "test.3" > test.3
> echo "log.1" > log.1
> echo "log.2" > log.2
> echo "log.3" > log.3

# Create release with test.{1,2,3} files with glob pattern test.*
> .\gh.exe release create --repo iamazeem/test --draft --notes "Test" v1.2.3 test.*
https://github.com/iamazeem/test/releases/tag/untagged-d72acf4185040741cacb

# Verify release
> .\gh.exe release view --repo iamazeem/test v1.2.3
v1.2.3
Draft • iamazeem created this less than a minute ago

  Test


Assets
test.1  18 B
test.2  18 B
test.3  18 B

View on GitHub: https://github.com/iamazeem/test/releases/tag/untagged-d72acf4185040741cacb


# Upload log.{1,2,3} files to release with glob pattern log.*
> .\gh.exe release upload --repo iamazeem/test v1.2.3 log.*
Successfully uploaded 3 assets to v1.2.3

# Verify release again
> .\gh.exe release view --repo iamazeem/test v1.2.3
v1.2.3
Draft • iamazeem created this about 2 minutes ago

  Test


Assets
log.1   16 B
log.2   16 B
log.3   16 B
test.1  18 B
test.2  18 B
test.3  18 B

View on GitHub: https://github.com/iamazeem/test/releases/tag/untagged-d72acf4185040741cacb
```

#### Invalid patterns

```powershell
# Create release with a glob pattern which doesn't expand e.g. test123.*
> .\gh.exe release create --repo iamazeem/test --draft --notes "Test" v1.2.3 test123.*
CreateFile test123.*: The filename, directory name, or volume label syntax is incorrect.
```

**Notes**

- In **Powershell**, quoting (single or double) glob patterns work the same and the quotes are not passed to `gh`.
- In **Command Prompt**, double quotes work same as Powershell but single quotes become part of the pattern itself.
  - For example, `'test.*'` is passed as-is and cannot be expanded.

**Error on Command Prompt**

```cmd
> .\gh.exe release create --repo iamazeem/test --draft --notes "Test" v1.2.3 'test.*'
CreateFile 'test.*': The filename, directory name, or volume label syntax is incorrect.
```
---

## UPDATE

After discussions, the support for glob patterns has been added for all the platforms.

Patch:
diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go
index e9d9f2c14..5392b997e 100644
--- a/pkg/cmd/gist/create/create.go
+++ b/pkg/cmd/gist/create/create.go
@@ -48,13 +48,13 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
 	}
 
 	cmd := &cobra.Command{
-		Use:   "create [<filename>... | -]",
+		Use:   "create [<filename>... | <pattern>... | -]",
 		Short: "Create a new gist",
 		Long: heredoc.Docf(`
 			Create a new GitHub gist with given contents.
 
 			Gists can be created from one or multiple files. Alternatively, pass %[1]s-%[1]s as
-			file name to read from standard input.
+			filename to read from standard input.
 
 			By default, gists are secret; use %[1]s--public%[1]s to make publicly listed ones.
 		`, "`"),
@@ -68,6 +68,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
 			# Create a gist containing several files
 			$ gh gist create hello.py world.py cool.txt
 
+			# Create a gist containing several files using patterns
+			$ gh gist create *.md *.txt artifact.*
+
 			# Read from standard input to create a gist
 			$ gh gist create -
 
@@ -102,12 +105,23 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
 }
 
 func createRun(opts *CreateOptions) error {
-	fileArgs := opts.Filenames
-	if len(fileArgs) == 0 {
-		fileArgs = []string{"-"}
+
+	readFromStdInArg, filenames := cmdutil.Partition(opts.Filenames, func(f string) bool {
+		return f == "-"
+	})
+
+	filenames, err := cmdutil.GlobPaths(filenames)
+	if err != nil {
+		return err
+	}
+
+	filenames = append(filenames, readFromStdInArg...)
+
+	if len(filenames) == 0 {
+		filenames = []string{"-"}
 	}
 
-	files, err := processFiles(opts.IO.In, opts.FilenameOverride, fileArgs)
+	files, err := processFiles(opts.IO.In, opts.FilenameOverride, filenames)
 	if err != nil {
 		return fmt.Errorf("failed to collect files for posting: %w", err)
 	}
diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go
index be6d0f65a..7e3a1d43a 100644
--- a/pkg/cmd/release/create/create.go
+++ b/pkg/cmd/release/create/create.go
@@ -78,7 +78,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
 	cmd := &cobra.Command{
 		DisableFlagsInUseLine: true,
 
-		Use:   "create [<tag>] [<files>...]",
+		Use:   "create [<tag>] [<filename>... | <pattern>...]",
 		Short: "Create a new release",
 		Long: heredoc.Docf(`
 			Create a new GitHub Release for a repository.
diff --git a/pkg/cmd/release/shared/upload.go b/pkg/cmd/release/shared/upload.go
index 38add7425..ab7533320 100644
--- a/pkg/cmd/release/shared/upload.go
+++ b/pkg/cmd/release/shared/upload.go
@@ -15,6 +15,7 @@ import (
 
 	"github.com/cenkalti/backoff/v4"
 	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/pkg/cmdutil"
 	"golang.org/x/sync/errgroup"
 )
 
@@ -36,6 +37,17 @@ type AssetForUpload struct {
 }
 
 func AssetsFromArgs(args []string) (assets []*AssetForUpload, err error) {
+	labeledArgs, unlabeledArgs := cmdutil.Partition(args, func(arg string) bool {
+		return strings.Contains(arg, "#")
+	})
+
+	args, err = cmdutil.GlobPaths(unlabeledArgs)
+	if err != nil {
+		return nil, err
+	}
+
+	args = append(args, labeledArgs...)
+
 	for _, arg := range args {
 		var label string
 		fn := arg
diff --git a/pkg/cmdutil/args.go b/pkg/cmdutil/args.go
index 0f03a07bd..b0a22e9b5 100644
--- a/pkg/cmdutil/args.go
+++ b/pkg/cmdutil/args.go
@@ -2,6 +2,7 @@ package cmdutil
 
 import (
 	"fmt"
+	"path/filepath"
 
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
@@ -57,3 +58,43 @@ func NoArgsQuoteReminder(cmd *cobra.Command, args []string) error {
 
 	return FlagErrorf("%s", errMsg)
 }
+
+// Partition takes a slice of any type T and separates it into two slices
+// of the same type based on the provided predicate function. Any item
+// that returns true for the predicate will be included in the first slice
+// returned, and any item that returns false for the predicate will be
+// included in the second slice returned.
+func Partition[T any](slice []T, predicate func(T) bool) ([]T, []T) {
+	var matching, nonMatching []T
+	for _, item := range slice {
+		if predicate(item) {
+			matching = append(matching, item)
+		} else {
+			nonMatching = append(nonMatching, item)
+		}
+	}
+	return matching, nonMatching
+}
+
+// GlobPaths expands a list of file patterns into a list of file paths.
+// If no files match a pattern, that pattern will return an error.
+// If no pattern is passed, this returns an empty list and no error.
+//
+// For information on supported glob patterns, see
+// https://pkg.go.dev/path/filepath#Match
+func GlobPaths(patterns []string) ([]string, error) {
+	expansions := []string{}
+
+	for _, pattern := range patterns {
+		matches, err := filepath.Glob(pattern)
+		if err != nil {
+			return nil, fmt.Errorf("%s: %v", pattern, err)
+		}
+		if len(matches) == 0 {
+			return []string{}, fmt.Errorf("no matches found for `%s`", pattern)
+		}
+		expansions = append(expansions, matches...)
+	}
+
+	return expansions, nil
+}


Test patch:
diff --git a/pkg/cmd/gist/create/create_test.go b/pkg/cmd/gist/create/create_test.go
index 237486eb3..44f0ba284 100644
--- a/pkg/cmd/gist/create/create_test.go
+++ b/pkg/cmd/gist/create/create_test.go
@@ -225,7 +225,7 @@ func Test_createRun(t *testing.T) {
 			responseStatus: http.StatusOK,
 		},
 		{
-			name: "multiple files",
+			name: "when both a file and the stdin '-' are provided, it matches on all the files passed in and stdin",
 			opts: &CreateOptions{
 				Filenames: []string{fixtureFile, "-"},
 			},
@@ -248,6 +248,30 @@ func Test_createRun(t *testing.T) {
 			},
 			responseStatus: http.StatusOK,
 		},
+		{
+			name: "when both a file and the stdin '-' are provided, but '-' is not the last argument, it matches on all the files provided and stdin",
+			opts: &CreateOptions{
+				Filenames: []string{"-", fixtureFile},
+			},
+			stdin:      "cool stdin content",
+			wantOut:    "https://gist.github.com/aa5a315d61ae9438b18d\n",
+			wantStderr: "- Creating gist with multiple files\n✓ Created secret gist fixture.txt\n",
+			wantErr:    false,
+			wantParams: map[string]interface{}{
+				"description": "",
+				"updated_at":  "0001-01-01T00:00:00Z",
+				"public":      false,
+				"files": map[string]interface{}{
+					"fixture.txt": map[string]interface{}{
+						"content": "{}",
+					},
+					"gistfile1.txt": map[string]interface{}{
+						"content": "cool stdin content",
+					},
+				},
+			},
+			responseStatus: http.StatusOK,
+		},
 		{
 			name: "file with empty content",
 			opts: &CreateOptions{
diff --git a/pkg/cmdutil/args_test.go b/pkg/cmdutil/args_test.go
index db0b96510..4e880dd27 100644
--- a/pkg/cmdutil/args_test.go
+++ b/pkg/cmdutil/args_test.go
@@ -1,6 +1,14 @@
 package cmdutil
 
-import "testing"
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
 
 func TestMinimumArgs(t *testing.T) {
 	tests := []struct {
@@ -48,3 +56,207 @@ func TestMinimumNs_with_error(t *testing.T) {
 		}
 	}
 }
+
+func TestPartition(t *testing.T) {
+	tests := []struct {
+		name            string
+		slice           []any
+		predicate       func(any) bool
+		wantMatching    []any
+		wantNonMatching []any
+	}{
+		{
+			name:  "When the slice is empty, it returns two empty slices",
+			slice: []any{},
+			predicate: func(any) bool {
+				return true
+			},
+			wantMatching:    []any{},
+			wantNonMatching: []any{},
+		},
+		{
+			name: "when the slice has one element that satisfies the predicate, it returns a slice with that element and an empty slice",
+			slice: []any{
+				"foo",
+			},
+			predicate: func(any) bool {
+				return true
+			},
+			wantMatching:    []any{"foo"},
+			wantNonMatching: []any{},
+		},
+		{
+			name: "when the slice has one element that does not satisfy the predicate, it returns an empty slice and a slice with that element",
+			slice: []any{
+				"foo",
+			},
+			predicate: func(any) bool {
+				return false
+			},
+			wantMatching:    []any{},
+			wantNonMatching: []any{"foo"},
+		},
+		{
+			name: "when the slice has multiple elements, it returns a slice with the elements that satisfy the predicate and a slice with the elements that do not satisfy the predicate",
+			slice: []any{
+				"foo",
+				"bar",
+				"baz",
+			},
+			predicate: func(s any) bool {
+				return s.(string) != "foo"
+			},
+			wantMatching:    []any{"bar", "baz"},
+			wantNonMatching: []any{"foo"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotMatching, gotNonMatching := Partition(tt.slice, tt.predicate)
+			assert.ElementsMatch(t, tt.wantMatching, gotMatching)
+			assert.ElementsMatch(t, tt.wantNonMatching, gotNonMatching)
+		})
+	}
+}
+
+func TestGlobPaths(t *testing.T) {
+	tests := []struct {
+		name     string
+		os       string
+		patterns []string
+		wantOut  []string
+		wantErr  error
+	}{
+		{
+			name:     "When no patterns are passed, return an empty slice",
+			patterns: []string{},
+			wantOut:  []string{},
+			wantErr:  nil,
+		},
+		{
+			name:     "When no files match, it returns an empty expansions array with error",
+			patterns: []string{"foo"},
+			wantOut:  []string{},
+			wantErr:  errors.New("no matches found for `foo`"),
+		},
+		{
+			name: "When a single pattern, '*.txt' is passed with one match, it returns that match",
+			patterns: []string{
+				"*.txt",
+			},
+			wantOut: []string{
+				"rootFile.txt",
+			},
+			wantErr: nil,
+		},
+		{
+			name: "When a single pattern, '*/*.txt' is passed with multiple matches, it returns those matches",
+			patterns: []string{
+				"*/*.txt",
+			},
+			wantOut: []string{
+				filepath.Join("subDir1", "subDir1_file.txt"),
+				filepath.Join("subDir2", "subDir2_file.txt"),
+			},
+			wantErr: nil,
+		},
+		{
+			name: "When multiple patterns, '*/*.txt' and '*/*.go', are passed with multiple matches, it returns those matches",
+			patterns: []string{
+				"*/*.txt",
+				"*/*.go",
+			},
+			wantOut: []string{
+				filepath.Join("subDir1", "subDir1_file.txt"),
+				filepath.Join("subDir2", "subDir2_file.txt"),
+				filepath.Join("subDir2", "subDir2_file.go"),
+			},
+			wantErr: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			cleanupFn := createTestDir(t)
+			defer cleanupFn()
+
+			got, err := GlobPaths(tt.patterns)
+			if tt.wantErr != nil {
+				assert.EqualError(t, err, tt.wantErr.Error())
+			} else {
+				require.NoError(t, err)
+			}
+			assert.Equal(t, tt.wantOut, got)
+		})
+	}
+}
+
+// Creates a temporary directory with the structure below. Returns
+// a cleanup function that will remove the directory and all of its
+// contents. The cleanup function should be wrapped in a defer statement.
+//
+//	| root
+//	|-- rootFile.txt
+//	|-- subDir1
+//	|	|-- subDir1_file.txt
+//	|
+//	|-- subDir2
+//		|-- subDir2_file.go
+//		|-- subDir2_file.txt
+func createTestDir(t *testing.T) (cleanupFn func()) {
+	t.Helper()
+	// Make Directories
+	rootDir := t.TempDir()
+
+	// Move workspace to temporary directory
+	cwd, err := os.Getwd()
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = os.Chdir(rootDir)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Make subdirectories
+	err = os.Mkdir(filepath.Join(rootDir, "subDir1"), 0755)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = os.Mkdir(filepath.Join(rootDir, "subDir2"), 0755)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Make Files
+	err = os.WriteFile(filepath.Join(rootDir, "rootFile.txt"), []byte(""), 0644)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = os.WriteFile(filepath.Join(rootDir, "subDir1", "subDir1_file.txt"), []byte(""), 0o644)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = os.WriteFile(filepath.Join(rootDir, "subDir2", "subDir2_file.go"), []byte(""), 0o644)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = os.WriteFile(filepath.Join(rootDir, "subDir2", "subDir2_file.txt"), []byte(""), 0o644)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	cleanupFn = func() {
+		os.RemoveAll(rootDir)
+		err = os.Chdir(cwd)
+		if err != nil {
+			t.Fatal(err)
+		}
+	}
+	return cleanupFn
+}