Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@ $ gh sub-pattern -r main --tags v | jq -R . | jq -s .

Without any target flag and with a terminal attached, the extension lists the repository's
environments, branches, tags, `pull_request` and derived prefix globs (e.g. `release/*`) in
[fzf](https://github.com/junegunn/fzf). Pick one, or type your own ref/glob and press enter.
Without fzf it falls back to gh's built-in selector (no free-text input).
[fzf](https://github.com/junegunn/fzf). Mark several candidates with Tab, or type your own
ref/glob; Enter prints one pattern per selection. Without fzf it falls back to gh's built-in
multi-select (no free-text input).

## Subject format

Expand Down
39 changes: 24 additions & 15 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,17 @@ func run(cli CLI) error {
if !term.IsTerminal(os.Stdin) {
return errors.New("no --environment, --ref(s) or --tag(s) given and stdin is not a terminal")
}
choice, err := pick(repo.Host, fullName)
choices, err := pick(repo.Host, fullName)
if err != nil {
return err
}
suffix, err := parseChoice(choice)
if err != nil {
return err
for _, c := range choices {
suffix, err := parseChoice(c)
if err != nil {
return err
}
subs = append(subs, suffix)
}
subs = []string{suffix}
}
for _, s := range subs {
fmt.Fprintln(stdout, "repo:"+repoSegment(info, immutable)+":"+s)
Expand Down Expand Up @@ -183,7 +185,7 @@ func candidates(envs, branches, tags []string) []string {
return out
}

func pick(host, fullName string) (string, error) {
func pick(host, fullName string) ([]string, error) {
list := func(path, jq string) []string {
out, _, err := gh.Exec("api", "--hostname", host, "--paginate", "repos/"+fullName+"/"+path, "--jq", jq)
if err != nil {
Expand All @@ -200,16 +202,23 @@ func pick(host, fullName string) (string, error) {
return fzf(items)
}
// shortcut: no free-text glob without fzf; add prompter.Input if non-fzf users need it.
idx, err := prompter.New(os.Stdin, os.Stderr, os.Stderr).Select("sub for "+fullName, "", items)
idxs, err := prompter.New(os.Stdin, os.Stderr, os.Stderr).MultiSelect("sub for "+fullName, nil, items)
if err != nil {
return "", err
return nil, err
}
if len(idxs) == 0 {
return nil, errors.New("nothing selected")
}
picked := make([]string, len(idxs))
for i, idx := range idxs {
picked[i] = items[idx]
}
return items[idx], nil
return picked, nil
}

func fzf(items []string) (string, error) {
cmd := exec.Command("fzf", "--print-query", "--exact", "--no-multi", "--prompt", "sub> ",
"--header", "Pick a candidate or type a ref/glob (e.g. release/*) and press enter")
func fzf(items []string) ([]string, error) {
cmd := exec.Command("fzf", "--print-query", "--exact", "--multi", "--prompt", "sub> ",
"--header", "Tab marks several candidates; or type a ref/glob (e.g. release/*). Enter to finish")
cmd.Stdin = strings.NewReader(strings.Join(items, "\n"))
cmd.Stderr = os.Stderr
out, err := cmd.Output()
Expand All @@ -218,11 +227,11 @@ func fzf(items []string) (string, error) {
noMatch := errors.As(err, &exit) && exit.ExitCode() == 1
switch {
case err == nil && len(lines) > 1:
return lines[1], nil
return lines[1:], nil
case (err == nil || noMatch) && lines[0] != "":
return lines[0], nil // free-text query
return lines[:1], nil // free-text query
default:
return "", errors.New("selection cancelled")
return nil, errors.New("selection cancelled")
}
}

Expand Down
17 changes: 10 additions & 7 deletions run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -127,17 +128,19 @@ func TestResolveRepoCurrent(t *testing.T) {

func TestFzf(t *testing.T) {
for name, tc := range map[string]struct {
script, want string
wantErr bool
script string
want []string
wantErr bool
}{
"selection": {`printf 'ma\nref:refs/heads/main\n'`, "ref:refs/heads/main", false},
"free text": {`printf 'release/2.x/*\n'; exit 1`, "release/2.x/*", false},
"cancelled": {`exit 130`, "", true},
"selection": {`printf 'ma\nref:refs/heads/main\n'`, []string{"ref:refs/heads/main"}, false},
"multi": {`printf '\npull_request\nref:refs/tags/*\n'`, []string{"pull_request", "ref:refs/tags/*"}, false},
"free text": {`printf 'release/2.x/*\n'; exit 1`, []string{"release/2.x/*"}, false},
"cancelled": {`exit 130`, nil, true},
} {
t.Run(name, func(t *testing.T) {
fakeBin(t, "fzf", "cat >/dev/null\n"+tc.script)
got, err := fzf([]string{"ref:refs/heads/main"})
if (err != nil) != tc.wantErr || got != tc.want {
if (err != nil) != tc.wantErr || !slices.Equal(got, tc.want) {
t.Errorf("got %q, %v", got, err)
}
})
Expand All @@ -154,7 +157,7 @@ esac`))
fakeBin(t, "fzf", `cat > "`+cands+`"; printf '\nref:refs/heads/release/*\n'`)

got, err := pick("github.com", "octo-org/octo-repo")
if err != nil || got != "ref:refs/heads/release/*" {
if err != nil || !slices.Equal(got, []string{"ref:refs/heads/release/*"}) {
t.Fatalf("got %q, %v", got, err)
}
listed, _ := os.ReadFile(cands)
Expand Down
Loading