From 350a1d642830ed79e7326c6130248457d716b66a Mon Sep 17 00:00:00 2001 From: scarf Date: Thu, 11 Sep 2025 01:28:55 +0900 Subject: [PATCH 1/9] build: customizable install `prefix` --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f823f6e938e..5529113d443 100644 --- a/Makefile +++ b/Makefile @@ -74,7 +74,7 @@ endif ## Install/uninstall tasks are here for use on *nix platform. On Windows, there is no equivalent. DESTDIR := -prefix := /usr/local +prefix ?= /usr/local bindir := ${prefix}/bin datadir := ${prefix}/share mandir := ${datadir}/man From b38f6772e5741b754ff88e8dd464293eadd083c4 Mon Sep 17 00:00:00 2001 From: gunadhya <6939749+gunadhya@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:31:22 +0530 Subject: [PATCH 2/9] Fix issue develop repeated invocation with named branches --- pkg/cmd/issue/develop/develop.go | 98 +++++++++++-- pkg/cmd/issue/develop/develop_test.go | 189 ++++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/issue/develop/develop.go b/pkg/cmd/issue/develop/develop.go index 04bf14ebe56..812194cf026 100644 --- a/pkg/cmd/issue/develop/develop.go +++ b/pkg/cmd/issue/develop/develop.go @@ -4,6 +4,8 @@ import ( ctx "context" "fmt" "net/http" + "net/url" + "strings" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" @@ -174,7 +176,6 @@ func developRun(opts *DevelopOptions) error { func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error { branchRepo := issueRepo - var repoID string if opts.BranchRepo != "" { var err error branchRepo, err = ghrepo.FromFullName(opts.BranchRepo) @@ -183,24 +184,66 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr } } - opts.IO.StartProgressIndicator() - repoID, branchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch) - opts.IO.StopProgressIndicator() - if err != nil { - return err + branchName := "" + reusedExisting := false + if opts.Name != "" { + opts.IO.StartProgressIndicator() + branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + branchName = findExistingLinkedBranchName(branches, branchRepo, opts.Name) + reusedExisting = branchName != "" } - opts.IO.StartProgressIndicator() - branchName, err := api.CreateLinkedBranch(apiClient, branchRepo.RepoHost(), repoID, issue.ID, branchID, opts.Name) - opts.IO.StopProgressIndicator() - if err != nil { - return err + repoID := "" + branchID := "" + baseValidated := false + if opts.BaseBranch != "" { + opts.IO.StartProgressIndicator() + foundRepoID, foundBranchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + repoID = foundRepoID + branchID = foundBranchID + baseValidated = true + } + + if branchName == "" { + if !baseValidated { + opts.IO.StartProgressIndicator() + foundRepoID, foundBranchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + repoID = foundRepoID + branchID = foundBranchID + } + + opts.IO.StartProgressIndicator() + createdBranchName, err := api.CreateLinkedBranch(apiClient, branchRepo.RepoHost(), repoID, issue.ID, branchID, opts.Name) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + branchName = createdBranchName + } + + if branchName == "" { + return fmt.Errorf("failed to create linked branch: API returned empty branch name") + } + + if reusedExisting && opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Using existing linked branch %q\n", branchName) } // Remember which branch to target when creating a PR. if opts.BaseBranch != "" { - err = opts.GitClient.SetBranchConfig(ctx.Background(), branchName, git.MergeBaseConfig, opts.BaseBranch) - if err != nil { + if err := opts.GitClient.SetBranchConfig(ctx.Background(), branchName, git.MergeBaseConfig, opts.BaseBranch); err != nil { return err } } @@ -210,6 +253,35 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr return checkoutBranch(opts, branchRepo, branchName) } +func findExistingLinkedBranchName(branches []api.LinkedBranch, branchRepo ghrepo.Interface, branchName string) string { + for _, branch := range branches { + if branch.BranchName != branchName { + continue + } + linkedRepo, err := linkedBranchRepoFromURL(branch.URL) + if err != nil { + continue + } + if ghrepo.IsSame(linkedRepo, branchRepo) { + return branch.BranchName + } + } + return "" +} + +func linkedBranchRepoFromURL(branchURL string) (ghrepo.Interface, error) { + u, err := url.Parse(branchURL) + if err != nil { + return nil, err + } + pathParts := strings.SplitN(strings.Trim(u.Path, "/"), "/", 3) + if len(pathParts) < 2 { + return nil, fmt.Errorf("invalid linked branch URL: %q", branchURL) + } + u.Path = "/" + strings.Join(pathParts[0:2], "/") + return ghrepo.FromURL(u) +} + func developRunList(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error { opts.IO.StartProgressIndicator() branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number) diff --git a/pkg/cmd/issue/develop/develop_test.go b/pkg/cmd/issue/develop/develop_test.go index 2485c8cc4cf..fe984df79dd 100644 --- a/pkg/cmd/issue/develop/develop_test.go +++ b/pkg/cmd/issue/develop/develop_test.go @@ -353,6 +353,16 @@ func TestDevelopRun(t *testing.T) { reg.Register( httpmock.GraphQL(`query FindRepoBranchID\b`), httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`)) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(123), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) reg.Register( httpmock.GraphQL(`mutation CreateLinkedBranch\b`), httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`, @@ -370,6 +380,165 @@ func TestDevelopRun(t *testing.T) { }, expectedOut: "github.com/OWNER/REPO/tree/my-branch\n", }, + { + name: "develop existing linked branch with name and checkout", + opts: &DevelopOptions{ + Name: "my-branch", + BaseBranch: "main", + IssueNumber: 123, + Checkout: true, + }, + remotes: map[string]string{ + "origin": "OWNER/REPO", + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranchFeature\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`), + ) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"my-branch","repository":{"url":"https://github.com/OWNER/REPO"}}}]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(123), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query FindRepoBranchID\b`), + httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`)) + }, + runStubs: func(cs *run.CommandStubber) { + cs.Register(`git config branch\.my-branch\.gh-merge-base main`, 0, "") + cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "") + cs.Register(`git rev-parse --verify refs/heads/my-branch`, 0, "") + cs.Register(`git checkout my-branch`, 0, "") + cs.Register(`git pull --ff-only origin my-branch`, 0, "") + }, + expectedOut: "github.com/OWNER/REPO/tree/my-branch\n", + }, + { + name: "develop existing linked branch with name in tty shows reuse message", + opts: &DevelopOptions{ + Name: "my-branch", + BaseBranch: "main", + IssueNumber: 123, + }, + tty: true, + remotes: map[string]string{ + "origin": "OWNER/REPO", + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranchFeature\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`), + ) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"my-branch","repository":{"url":"https://github.com/OWNER/REPO"}}}]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(123), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query FindRepoBranchID\b`), + httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`)) + }, + runStubs: func(cs *run.CommandStubber) { + cs.Register(`git config branch\.my-branch\.gh-merge-base main`, 0, "") + cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "") + }, + expectedOut: "github.com/OWNER/REPO/tree/my-branch\n", + expectedErrOut: "Using existing linked branch \"my-branch\"\n", + }, + { + name: "develop existing linked branch with invalid base branch returns an error", + opts: &DevelopOptions{ + Name: "my-branch", + BaseBranch: "does-not-exist-branch", + IssueNumber: 123, + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranchFeature\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`), + ) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"my-branch","repository":{"url":"https://github.com/OWNER/REPO"}}}]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(123), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query FindRepoBranchID\b`), + httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","defaultBranchRef":{"target":{"oid":"DEFAULTOID"}},"ref":null}}}`), + ) + }, + wantErr: `could not find branch "does-not-exist-branch" in OWNER/REPO`, + }, + { + name: "develop with empty linked branch name response returns an error", + opts: &DevelopOptions{ + Name: "my-branch", + BaseBranch: "main", + IssueNumber: 123, + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`query LinkedBranchFeature\b`), + httpmock.StringResponse(featureEnabledPayload), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`), + ) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(123), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query FindRepoBranchID\b`), + httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`)) + reg.Register( + httpmock.GraphQL(`mutation CreateLinkedBranch\b`), + httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":""}}}}}`, + func(inputs map[string]interface{}) { + assert.Equal(t, "REPOID", inputs["repositoryId"]) + assert.Equal(t, "SOMEID", inputs["issueId"]) + assert.Equal(t, "OID", inputs["oid"]) + assert.Equal(t, "my-branch", inputs["name"]) + }), + ) + }, + wantErr: "failed to create linked branch: API returned empty branch name", + }, { name: "develop new branch outside of local git repo", opts: &DevelopOptions{ @@ -426,6 +595,16 @@ func TestDevelopRun(t *testing.T) { httpmock.GraphQL(`query FindRepoBranchID\b`), httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`), ) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(123), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) reg.Register( httpmock.GraphQL(`mutation CreateLinkedBranch\b`), httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`, @@ -468,6 +647,16 @@ func TestDevelopRun(t *testing.T) { httpmock.GraphQL(`query FindRepoBranchID\b`), httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`), ) + reg.Register( + httpmock.GraphQL(`query ListLinkedBranches\b`), + httpmock.GraphQLQuery(` + {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}} + `, func(query string, inputs map[string]interface{}) { + assert.Equal(t, float64(123), inputs["number"]) + assert.Equal(t, "OWNER", inputs["owner"]) + assert.Equal(t, "REPO", inputs["name"]) + }), + ) reg.Register( httpmock.GraphQL(`mutation CreateLinkedBranch\b`), httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`, From fa95f3a21b2bd525e834786594054e513fdb9eeb Mon Sep 17 00:00:00 2001 From: 4RH1T3CT0R7 Date: Sat, 14 Feb 2026 20:31:02 +0300 Subject: [PATCH 3/9] Add --no-upstream flag to gh repo clone When cloning a forked repository, `gh repo clone` automatically adds the parent repo as an `upstream` remote and sets it as the default repository. This can be problematic when the user lacks access to the parent repo, the upstream fetch is expensive for large repos, or the user simply doesn't want the upstream remote. Add a `--no-upstream` flag that skips adding the upstream remote when cloning a fork. When used, origin (the fork) is set as the default repository instead. The flag is mutually exclusive with `--upstream-remote-name`. For non-fork repos the flag is a harmless no-op. Closes #8274 --- pkg/cmd/repo/clone/clone.go | 62 +++++++++++++++---------- pkg/cmd/repo/clone/clone_test.go | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 24 deletions(-) diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index 1466cd96a0d..7c538214484 100644 --- a/pkg/cmd/repo/clone/clone.go +++ b/pkg/cmd/repo/clone/clone.go @@ -27,6 +27,7 @@ type CloneOptions struct { GitArgs []string Repository string UpstreamName string + NoUpstream bool } func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Command { @@ -60,6 +61,8 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm the remote after the owner of the parent repository. If the repository is a fork, its parent repository will be set as the default remote repository. + + To skip adding the upstream remote entirely, use %[1]s--no-upstream%[1]s. `, "`"), Example: heredoc.Doc(` # Clone a repository from a specific org @@ -77,6 +80,9 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm # Clone a repository with additional git clone flags $ gh repo clone cli/cli -- --depth=1 + + # Clone a fork without adding an upstream remote + $ gh repo clone myfork --no-upstream `), RunE: func(cmd *cobra.Command, args []string) error { opts.Repository = args[0] @@ -91,6 +97,8 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm } cmd.Flags().StringVarP(&opts.UpstreamName, "upstream-remote-name", "u", "upstream", "Upstream remote name when cloning a fork") + cmd.Flags().BoolVar(&opts.NoUpstream, "no-upstream", false, "Do not add an upstream remote when cloning a fork") + cmd.MarkFlagsMutuallyExclusive("upstream-remote-name", "no-upstream") cmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { if err == pflag.ErrHelp { return err @@ -187,37 +195,43 @@ func cloneRun(opts *CloneOptions) error { // If the repo is a fork, add the parent as an upstream remote and set the parent as the default repo. if canonicalRepo.Parent != nil { - protocol := cfg.GitProtocol(canonicalRepo.Parent.RepoHost()).Value - upstreamURL := ghrepo.FormatRemoteURL(canonicalRepo.Parent, protocol) - - upstreamName := opts.UpstreamName - if opts.UpstreamName == "@owner" { - upstreamName = canonicalRepo.Parent.RepoOwner() - } - gc := gitClient.Copy() gc.RepoDir = cloneDir - if _, err := gc.AddRemote(ctx, upstreamName, upstreamURL, []string{canonicalRepo.Parent.DefaultBranchRef.Name}); err != nil { - return err - } + if opts.NoUpstream { + if err := gc.SetRemoteResolution(ctx, "origin", "base"); err != nil { + return err + } + } else { + protocol := cfg.GitProtocol(canonicalRepo.Parent.RepoHost()).Value + upstreamURL := ghrepo.FormatRemoteURL(canonicalRepo.Parent, protocol) - if err := gc.Fetch(ctx, upstreamName, ""); err != nil { - return err - } + upstreamName := opts.UpstreamName + if opts.UpstreamName == "@owner" { + upstreamName = canonicalRepo.Parent.RepoOwner() + } - if err := gc.SetRemoteBranches(ctx, upstreamName, `*`); err != nil { - return err - } + if _, err := gc.AddRemote(ctx, upstreamName, upstreamURL, []string{canonicalRepo.Parent.DefaultBranchRef.Name}); err != nil { + return err + } - if err = gc.SetRemoteResolution(ctx, upstreamName, "base"); err != nil { - return err - } + if err := gc.Fetch(ctx, upstreamName, ""); err != nil { + return err + } - connectedToTerminal := opts.IO.IsStdoutTTY() - if connectedToTerminal { - cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(canonicalRepo.Parent))) + if err := gc.SetRemoteBranches(ctx, upstreamName, `*`); err != nil { + return err + } + + if err := gc.SetRemoteResolution(ctx, upstreamName, "base"); err != nil { + return err + } + + connectedToTerminal := opts.IO.IsStdoutTTY() + if connectedToTerminal { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(canonicalRepo.Parent))) + } } } return nil diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go index 471ed05ddc2..bab2bd670bd 100644 --- a/pkg/cmd/repo/clone/clone_test.go +++ b/pkg/cmd/repo/clone/clone_test.go @@ -54,6 +54,20 @@ func TestNewCmdClone(t *testing.T) { GitArgs: []string{"--depth", "1", "--recurse-submodules"}, }, }, + { + name: "no-upstream flag", + args: "OWNER/REPO --no-upstream", + wantOpts: CloneOptions{ + Repository: "OWNER/REPO", + GitArgs: []string{}, + NoUpstream: true, + }, + }, + { + name: "no-upstream with upstream-remote-name", + args: "OWNER/REPO --no-upstream --upstream-remote-name test", + wantErr: "if any flags in the group [upstream-remote-name no-upstream] are set none of the others can be; [no-upstream upstream-remote-name] were all set", + }, { name: "unknown argument", args: "OWNER/REPO --depth 1", @@ -92,6 +106,7 @@ func TestNewCmdClone(t *testing.T) { assert.Equal(t, tt.wantOpts.Repository, opts.Repository) assert.Equal(t, tt.wantOpts.GitArgs, opts.GitArgs) + assert.Equal(t, tt.wantOpts.NoUpstream, opts.NoUpstream) }) } } @@ -344,6 +359,70 @@ func Test_RepoClone_withoutUsername(t *testing.T) { assert.Equal(t, "", output.Stderr()) } +func Test_RepoClone_hasParent_noUpstream(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "name": "REPO", + "owner": { + "login": "OWNER" + }, + "parent": { + "name": "ORIG", + "owner": { + "login": "hubot" + }, + "defaultBranchRef": { + "name": "trunk" + } + } + } } } + `)) + + httpClient := &http.Client{Transport: reg} + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git clone https://github.com/OWNER/REPO.git`, 0, "") + cs.Register(`git -C REPO config --add remote.origin.gh-resolved base`, 0, "") + + _, err := runCloneCommand(httpClient, "OWNER/REPO --no-upstream") + if err != nil { + t.Fatalf("error running command `repo clone`: %v", err) + } +} + +func Test_RepoClone_noParent_noUpstream(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "name": "REPO", + "owner": { + "login": "OWNER" + } + } } } + `)) + + httpClient := &http.Client{Transport: reg} + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git clone https://github.com/OWNER/REPO.git`, 0, "") + + _, err := runCloneCommand(httpClient, "OWNER/REPO --no-upstream") + if err != nil { + t.Fatalf("error running command `repo clone`: %v", err) + } +} + func TestSimplifyURL(t *testing.T) { tests := []struct { name string From 01c83acfe837660712b3fd5227176cac2275760a Mon Sep 17 00:00:00 2001 From: Takeshi Date: Sat, 28 Feb 2026 20:01:38 -0500 Subject: [PATCH 4/9] Add --duplicate-of flag and duplicate reason to gh issue close Support closing issues as duplicates via --reason duplicate and --duplicate-of flags. The --duplicate-of flag accepts an issue number or URL, validates it references a different issue (not a PR), and passes the duplicate issue ID to the closeIssue mutation. Feature detection checks whether the GHES instance supports the DUPLICATE enum value in IssueClosedStateReason before using it. --- .../featuredetection/feature_detection.go | 39 ++- .../feature_detection_test.go | 51 +++- pkg/cmd/issue/close/close.go | 72 +++++- pkg/cmd/issue/close/close_test.go | 228 ++++++++++++++++++ 4 files changed, 368 insertions(+), 22 deletions(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 7a200e20c84..b162e4c2cf3 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -23,13 +23,15 @@ type Detector interface { } type IssueFeatures struct { - StateReason bool - ActorIsAssignable bool + StateReason bool + StateReasonDuplicate bool + ActorIsAssignable bool } var allIssueFeatures = IssueFeatures{ - StateReason: true, - ActorIsAssignable: true, + StateReason: true, + StateReasonDuplicate: true, + ActorIsAssignable: true, } type PullRequestFeatures struct { @@ -138,8 +140,9 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { } features := IssueFeatures{ - StateReason: false, - ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES + StateReason: false, + StateReasonDuplicate: false, + ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES } var featureDetection struct { @@ -162,6 +165,30 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { } } + if !features.StateReason { + return features, nil + } + + var issueClosedStateReasonFeatureDetection struct { + IssueClosedStateReason struct { + EnumValues []struct { + Name string + } `graphql:"enumValues(includeDeprecated: true)"` + } `graphql:"IssueClosedStateReason: __type(name: \"IssueClosedStateReason\")"` + } + + err = gql.Query(d.host, "IssueClosedStateReason_enumValues", &issueClosedStateReasonFeatureDetection, nil) + if err != nil { + return features, err + } + + for _, enumValue := range issueClosedStateReasonFeatureDetection.IssueClosedStateReason.EnumValues { + if enumValue.Name == "DUPLICATE" { + features.StateReasonDuplicate = true + break + } + } + return features, nil } diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index 032f5cda03c..c1d7b3b4a70 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -23,8 +23,9 @@ func TestIssueFeatures(t *testing.T) { name: "github.com", hostname: "github.com", wantFeatures: IssueFeatures{ - StateReason: true, - ActorIsAssignable: true, + StateReason: true, + StateReasonDuplicate: true, + ActorIsAssignable: true, }, wantErr: false, }, @@ -32,8 +33,9 @@ func TestIssueFeatures(t *testing.T) { name: "ghec data residency (ghe.com)", hostname: "stampname.ghe.com", wantFeatures: IssueFeatures{ - StateReason: true, - ActorIsAssignable: true, + StateReason: true, + StateReasonDuplicate: true, + ActorIsAssignable: true, }, wantErr: false, }, @@ -44,13 +46,14 @@ func TestIssueFeatures(t *testing.T) { `query Issue_fields\b`: `{"data": {}}`, }, wantFeatures: IssueFeatures{ - StateReason: false, - ActorIsAssignable: false, + StateReason: false, + StateReasonDuplicate: false, + ActorIsAssignable: false, }, wantErr: false, }, { - name: "GHE has state reason field", + name: "GHE has state reason field without duplicate enum", hostname: "git.my.org", queryResponse: map[string]string{ `query Issue_fields\b`: heredoc.Doc(` @@ -58,9 +61,41 @@ func TestIssueFeatures(t *testing.T) { {"name": "stateReason"} ] } } } `), + `query IssueClosedStateReason_enumValues\b`: heredoc.Doc(` + { "data": { "IssueClosedStateReason": { "enumValues": [ + {"name": "COMPLETED"}, + {"name": "NOT_PLANNED"} + ] } } } + `), + }, + wantFeatures: IssueFeatures{ + StateReason: true, + StateReasonDuplicate: false, + ActorIsAssignable: false, + }, + wantErr: false, + }, + { + name: "GHE has duplicate state reason enum value", + hostname: "git.my.org", + queryResponse: map[string]string{ + `query Issue_fields\b`: heredoc.Doc(` + { "data": { "Issue": { "fields": [ + {"name": "stateReason"} + ] } } } + `), + `query IssueClosedStateReason_enumValues\b`: heredoc.Doc(` + { "data": { "IssueClosedStateReason": { "enumValues": [ + {"name": "COMPLETED"}, + {"name": "NOT_PLANNED"}, + {"name": "DUPLICATE"} + ] } } } + `), }, wantFeatures: IssueFeatures{ - StateReason: true, + StateReason: true, + StateReasonDuplicate: true, + ActorIsAssignable: false, }, wantErr: false, }, diff --git a/pkg/cmd/issue/close/close.go b/pkg/cmd/issue/close/close.go index c61d1d917fe..19510bf89a9 100644 --- a/pkg/cmd/issue/close/close.go +++ b/pkg/cmd/issue/close/close.go @@ -24,6 +24,7 @@ type CloseOptions struct { IssueNumber int Comment string Reason string + DuplicateOf string Detector fd.Detector } @@ -55,6 +56,13 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm } opts.IssueNumber = issueNumber + if opts.DuplicateOf != "" { + if opts.Reason == "" { + opts.Reason = "duplicate" + } else if opts.Reason != "duplicate" { + return cmdutil.FlagErrorf("`--duplicate-of` can only be used with `--reason duplicate`") + } + } if runF != nil { return runF(opts) @@ -64,13 +72,22 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm } cmd.Flags().StringVarP(&opts.Comment, "comment", "c", "", "Leave a closing comment") - cmdutil.StringEnumFlag(cmd, &opts.Reason, "reason", "r", "", []string{"completed", "not planned"}, "Reason for closing") + cmdutil.StringEnumFlag(cmd, &opts.Reason, "reason", "r", "", []string{"completed", "not planned", "duplicate"}, "Reason for closing") + cmd.Flags().StringVar(&opts.DuplicateOf, "duplicate-of", "", "Mark as duplicate of another issue by number or URL") return cmd } func closeRun(opts *CloseOptions) error { cs := opts.IO.ColorScheme() + closeReason := opts.Reason + if opts.DuplicateOf != "" { + if closeReason == "" { + closeReason = "duplicate" + } else if closeReason != "duplicate" { + return cmdutil.FlagErrorf("`--duplicate-of` can only be used with `--reason duplicate`") + } + } httpClient, err := opts.HttpClient() if err != nil { @@ -92,6 +109,32 @@ func closeRun(opts *CloseOptions) error { return nil } + var duplicateIssueID string + if opts.DuplicateOf != "" { + if issue.IsPullRequest() { + return cmdutil.FlagErrorf("`--duplicate-of` is only supported for issues") + } + duplicateIssueNumber, duplicateRepo, err := shared.ParseIssueFromArg(opts.DuplicateOf) + if err != nil { + return cmdutil.FlagErrorf("invalid value for `--duplicate-of`: %v", err) + } + duplicateIssueRepo := baseRepo + if parsedRepo, present := duplicateRepo.Value(); present { + duplicateIssueRepo = parsedRepo + } + if ghrepo.IsSame(baseRepo, duplicateIssueRepo) && issue.Number == duplicateIssueNumber { + return cmdutil.FlagErrorf("`--duplicate-of` cannot reference the current issue") + } + duplicateIssue, err := shared.FindIssueOrPR(httpClient, duplicateIssueRepo, duplicateIssueNumber, []string{"id"}) + if err != nil { + return err + } + if duplicateIssue.IsPullRequest() { + return cmdutil.FlagErrorf("`--duplicate-of` must reference an issue") + } + duplicateIssueID = duplicateIssue.ID + } + if opts.Comment != "" { commentOpts := &prShared.CommentableOptions{ Body: opts.Comment, @@ -108,7 +151,7 @@ func closeRun(opts *CloseOptions) error { } } - err = apiClose(httpClient, baseRepo, issue, opts.Detector, opts.Reason) + err = apiClose(httpClient, baseRepo, issue, opts.Detector, closeReason, duplicateIssueID) if err != nil { return err } @@ -118,12 +161,12 @@ func closeRun(opts *CloseOptions) error { return nil } -func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, detector fd.Detector, reason string) error { +func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, detector fd.Detector, reason string, duplicateIssueID string) error { if issue.IsPullRequest() { return api.PullRequestClose(httpClient, repo, issue.ID) } - if reason != "" { + if reason != "" || duplicateIssueID != "" { if detector == nil { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) detector = fd.NewDetector(cachedClient, repo.RepoHost()) @@ -135,6 +178,15 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, // TODO stateReasonCleanup if !features.StateReason { // If StateReason is not supported silently close issue without setting StateReason. + if duplicateIssueID != "" { + return fmt.Errorf("closing as duplicate is not supported on %s", repo.RepoHost()) + } + reason = "" + } else if reason == "duplicate" && !features.StateReasonDuplicate { + if duplicateIssueID != "" { + return fmt.Errorf("closing as duplicate is not supported on %s", repo.RepoHost()) + } + // If DUPLICATE is not supported silently close issue without setting StateReason. reason = "" } } @@ -144,6 +196,8 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, // If no reason is specified do not set it. case "not planned": reason = "NOT_PLANNED" + case "duplicate": + reason = "DUPLICATE" default: reason = "COMPLETED" } @@ -158,8 +212,9 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, variables := map[string]interface{}{ "input": CloseIssueInput{ - IssueID: issue.ID, - StateReason: reason, + IssueID: issue.ID, + StateReason: reason, + DuplicateIssueID: duplicateIssueID, }, } @@ -168,6 +223,7 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, } type CloseIssueInput struct { - IssueID string `json:"issueId"` - StateReason string `json:"stateReason,omitempty"` + IssueID string `json:"issueId"` + StateReason string `json:"stateReason,omitempty"` + DuplicateIssueID string `json:"duplicateIssueId,omitempty"` } diff --git a/pkg/cmd/issue/close/close_test.go b/pkg/cmd/issue/close/close_test.go index 04c39cd8da0..ddab7121007 100644 --- a/pkg/cmd/issue/close/close_test.go +++ b/pkg/cmd/issue/close/close_test.go @@ -16,6 +16,15 @@ import ( "github.com/stretchr/testify/require" ) +type issueFeaturesDetectorMock struct { + fd.EnabledDetectorMock + issueFeatures fd.IssueFeatures +} + +func (md *issueFeaturesDetectorMock) IssueFeatures() (fd.IssueFeatures, error) { + return md.issueFeatures, nil +} + func TestNewCmdClose(t *testing.T) { // Test shared parsing of issue number / URL. argparsetest.TestArgParsing(t, NewCmdClose) @@ -44,6 +53,29 @@ func TestNewCmdClose(t *testing.T) { Reason: "not planned", }, }, + { + name: "reason duplicate", + input: "123 --reason duplicate", + output: CloseOptions{ + IssueNumber: 123, + Reason: "duplicate", + }, + }, + { + name: "duplicate of sets duplicate reason", + input: "123 --duplicate-of 456", + output: CloseOptions{ + IssueNumber: 123, + Reason: "duplicate", + DuplicateOf: "456", + }, + }, + { + name: "duplicate of with invalid reason", + input: "123 --reason completed --duplicate-of 456", + wantErr: true, + errMsg: "`--duplicate-of` can only be used with `--reason duplicate`", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -74,6 +106,7 @@ func TestNewCmdClose(t *testing.T) { assert.Equal(t, tt.output.IssueNumber, gotOpts.IssueNumber) assert.Equal(t, tt.output.Comment, gotOpts.Comment) assert.Equal(t, tt.output.Reason, gotOpts.Reason) + assert.Equal(t, tt.output.DuplicateOf, gotOpts.DuplicateOf) if tt.expectedBaseRepo != nil { baseRepo, err := gotOpts.BaseRepo() require.NoError(t, err) @@ -184,6 +217,201 @@ func TestCloseRun(t *testing.T) { }, wantStderr: "✓ Closed issue OWNER/REPO#13 (The title of the issue)\n", }, + { + name: "close issue with duplicate reason", + opts: &CloseOptions{ + IssueNumber: 13, + Reason: "duplicate", + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`mutation IssueClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, 2, len(inputs)) + assert.Equal(t, "THE-ID", inputs["issueId"]) + assert.Equal(t, "DUPLICATE", inputs["stateReason"]) + }), + ) + }, + wantStderr: "✓ Closed issue OWNER/REPO#13 (The title of the issue)\n", + }, + { + name: "close issue as duplicate of another issue", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "99", + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "DUPLICATE-ID", "number": 99} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`mutation IssueClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, 3, len(inputs)) + assert.Equal(t, "THE-ID", inputs["issueId"]) + assert.Equal(t, "DUPLICATE", inputs["stateReason"]) + assert.Equal(t, "DUPLICATE-ID", inputs["duplicateIssueId"]) + }), + ) + }, + wantStderr: "✓ Closed issue OWNER/REPO#13 (The title of the issue)\n", + }, + { + name: "close issue with duplicate reason when duplicate is not supported", + opts: &CloseOptions{ + IssueNumber: 13, + Reason: "duplicate", + Detector: &issueFeaturesDetectorMock{ + issueFeatures: fd.IssueFeatures{ + StateReason: true, + StateReasonDuplicate: false, + }, + }, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`mutation IssueClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, 1, len(inputs)) + assert.Equal(t, "THE-ID", inputs["issueId"]) + }), + ) + }, + wantStderr: "✓ Closed issue OWNER/REPO#13 (The title of the issue)\n", + }, + { + name: "close issue as duplicate when duplicate is not supported", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "99", + Detector: &issueFeaturesDetectorMock{ + issueFeatures: fd.IssueFeatures{ + StateReason: true, + StateReasonDuplicate: false, + }, + }, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "DUPLICATE-ID", "number": 99} + } } }`), + ) + }, + wantErr: true, + errMsg: "closing as duplicate is not supported on github.com", + }, + { + name: "duplicate of cannot point to same issue", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "13", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + }, + wantErr: true, + errMsg: "`--duplicate-of` cannot reference the current issue", + }, + { + name: "duplicate of must reference an issue", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "99", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "__typename": "PullRequest", "id": "PULL-ID", "number": 99} + } } }`), + ) + }, + wantErr: true, + errMsg: "`--duplicate-of` must reference an issue", + }, + { + name: "duplicate of with invalid format", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "not-an-issue", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + }, + wantErr: true, + errMsg: "invalid value for `--duplicate-of`: invalid issue format: \"not-an-issue\"", + }, { name: "close issue with reason when reason is not supported", opts: &CloseOptions{ From de61b2b65dde6166720bbf68fc2abb9dc5efedee Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Tue, 10 Feb 2026 16:57:25 +0530 Subject: [PATCH 5/9] feat(pr): add changeType field to files JSON output Add the changeType field from the PullRequestChangedFile GraphQL type to the PullRequestFile struct. This exposes the file status (added, modified, deleted, renamed, copied, changed) in gh pr list --json files and gh pr view --json files output. Closes #11385 --- api/queries_pr.go | 7 ++++--- api/query_builder.go | 3 ++- api/query_builder_test.go | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 1bc5ddb55d7..c38018a68fe 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -296,9 +296,10 @@ type PullRequestCommitCommit struct { } type PullRequestFile struct { - Path string `json:"path"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` + Path string `json:"path"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + ChangeType string `json:"changeType"` } type ReviewRequests struct { diff --git a/api/query_builder.go b/api/query_builder.go index cb80b595f0c..c3e1e9ba3a9 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -148,7 +148,8 @@ var prFiles = shortenQuery(` nodes { additions, deletions, - path + path, + changeType } } `) diff --git a/api/query_builder_test.go b/api/query_builder_test.go index f854cd36ac2..f0b5789a3e6 100644 --- a/api/query_builder_test.go +++ b/api/query_builder_test.go @@ -26,7 +26,7 @@ func TestPullRequestGraphQL(t *testing.T) { { name: "compressed query", fields: []string{"files"}, - want: "files(first: 100) {nodes {additions,deletions,path}}", + want: "files(first: 100) {nodes {additions,deletions,path,changeType}}", }, { name: "invalid fields", @@ -72,7 +72,7 @@ func TestIssueGraphQL(t *testing.T) { { name: "compressed query", fields: []string{"files"}, - want: "files(first: 100) {nodes {additions,deletions,path}}", + want: "files(first: 100) {nodes {additions,deletions,path,changeType}}", }, { name: "projectItems", From ee8014a4d9b18290992393d87f8325f13e7bdd21 Mon Sep 17 00:00:00 2001 From: gunadhya <6939749+gunadhya@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:01:57 +0530 Subject: [PATCH 6/9] Simplify progress indicators in issue develop --- pkg/cmd/issue/develop/develop.go | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/issue/develop/develop.go b/pkg/cmd/issue/develop/develop.go index 812194cf026..90c52dff6e4 100644 --- a/pkg/cmd/issue/develop/develop.go +++ b/pkg/cmd/issue/develop/develop.go @@ -152,21 +152,22 @@ func developRun(opts *DevelopOptions) error { return err } - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Fetching issue #%d", opts.IssueNumber)) + defer opts.IO.StopProgressIndicator() + issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number"}) - opts.IO.StopProgressIndicator() if err != nil { return err } apiClient := api.NewClientFromHTTP(httpClient) - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel("Checking linked branch support") err = api.CheckLinkedBranchFeature(apiClient, baseRepo.RepoHost()) - opts.IO.StopProgressIndicator() if err != nil { return err } + opts.IO.StopProgressIndicator() if opts.List { return developRunList(opts, apiClient, baseRepo, issue) @@ -184,12 +185,14 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr } } + opts.IO.StartProgressIndicatorWithLabel("Preparing linked branch") + defer opts.IO.StopProgressIndicator() + branchName := "" reusedExisting := false if opts.Name != "" { - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel("Checking existing linked branches") branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number) - opts.IO.StopProgressIndicator() if err != nil { return err } @@ -201,9 +204,8 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr branchID := "" baseValidated := false if opts.BaseBranch != "" { - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Validating base branch %q", opts.BaseBranch)) foundRepoID, foundBranchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch) - opts.IO.StopProgressIndicator() if err != nil { return err } @@ -214,9 +216,8 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr if branchName == "" { if !baseValidated { - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel("Resolving base branch") foundRepoID, foundBranchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch) - opts.IO.StopProgressIndicator() if err != nil { return err } @@ -224,9 +225,8 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr branchID = foundBranchID } - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel("Creating linked branch") createdBranchName, err := api.CreateLinkedBranch(apiClient, branchRepo.RepoHost(), repoID, issue.ID, branchID, opts.Name) - opts.IO.StopProgressIndicator() if err != nil { return err } @@ -237,6 +237,8 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr return fmt.Errorf("failed to create linked branch: API returned empty branch name") } + opts.IO.StopProgressIndicator() + if reusedExisting && opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.ErrOut, "Using existing linked branch %q\n", branchName) } @@ -283,12 +285,14 @@ func linkedBranchRepoFromURL(branchURL string) (ghrepo.Interface, error) { } func developRunList(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error { - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel("Fetching linked branches") + defer opts.IO.StopProgressIndicator() + branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number) - opts.IO.StopProgressIndicator() if err != nil { return err } + opts.IO.StopProgressIndicator() if len(branches) == 0 { return cmdutil.NewNoResultsError(fmt.Sprintf("no linked branches found for %s#%d", ghrepo.FullName(issueRepo), issue.Number)) From 6045a593a35b9eb9913e89269fc326851145ec9a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:51:27 -0700 Subject: [PATCH 7/9] Reword `--no-upstream` help doc --- pkg/cmd/repo/clone/clone.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index 7c538214484..b29b25038b6 100644 --- a/pkg/cmd/repo/clone/clone.go +++ b/pkg/cmd/repo/clone/clone.go @@ -61,8 +61,7 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm the remote after the owner of the parent repository. If the repository is a fork, its parent repository will be set as the default remote repository. - - To skip adding the upstream remote entirely, use %[1]s--no-upstream%[1]s. + To skip this behavior, use %[1]s--no-upstream%[1]s. `, "`"), Example: heredoc.Doc(` # Clone a repository from a specific org From 74ff94c3e4d14e8a9dd1cf1d138eb17617e8b95e Mon Sep 17 00:00:00 2001 From: Mason McElvain <52104630+masonmcelvain@users.noreply.github.com> Date: Sat, 9 Aug 2025 08:19:51 -0600 Subject: [PATCH 8/9] feat(browse): add blame flag --- pkg/cmd/browse/browse.go | 16 +++++++ pkg/cmd/browse/browse_test.go | 79 +++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go index a85b8ab7de1..d057139565a 100644 --- a/pkg/cmd/browse/browse.go +++ b/pkg/cmd/browse/browse.go @@ -44,6 +44,7 @@ type BrowseOptions struct { SettingsFlag bool WikiFlag bool ActionsFlag bool + BlameFlag bool NoBrowserFlag bool HasRepoOverride bool } @@ -91,6 +92,9 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co # Open main.go at line 312 $ gh browse main.go:312 + # Open blame view for main.go at line 312 + $ gh browse main.go:312 --blame + # Open main.go with the repository at head of bug-fix branch $ gh browse main.go --branch bug-fix @@ -141,6 +145,10 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co return err } + if opts.BlameFlag && opts.SelectorArg == "" { + return cmdutil.FlagErrorf("`--blame` requires a file path argument") + } + if (isNumber(opts.SelectorArg) || isCommit(opts.SelectorArg)) && (opts.Branch != "" || opts.Commit != "") { return cmdutil.FlagErrorf("%q is an invalid argument when using `--branch` or `--commit`", opts.SelectorArg) } @@ -163,6 +171,7 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co cmd.Flags().BoolVarP(&opts.WikiFlag, "wiki", "w", false, "Open repository wiki") cmd.Flags().BoolVarP(&opts.ActionsFlag, "actions", "a", false, "Open repository actions") cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Open repository settings") + cmd.Flags().BoolVar(&opts.BlameFlag, "blame", false, "Open blame view for a file") cmd.Flags().BoolVarP(&opts.NoBrowserFlag, "no-browser", "n", false, "Print destination URL instead of opening the browser") cmd.Flags().StringVarP(&opts.Commit, "commit", "c", "", "Select another commit by passing in the commit SHA, default is the last commit") cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Select another branch by passing in the branch name") @@ -272,9 +281,16 @@ func parseSection(baseRepo ghrepo.Interface, opts *BrowseOptions) (string, error } else { rangeFragment = fmt.Sprintf("L%d", rangeStart) } + if opts.BlameFlag { + return fmt.Sprintf("blame/%s/%s#%s", escapePath(ref), escapePath(filePath), rangeFragment), nil + } return fmt.Sprintf("blob/%s/%s?plain=1#%s", escapePath(ref), escapePath(filePath), rangeFragment), nil } + if opts.BlameFlag { + return fmt.Sprintf("blame/%s/%s", escapePath(ref), escapePath(filePath)), nil + } + return strings.TrimSuffix(fmt.Sprintf("tree/%s/%s", escapePath(ref), escapePath(filePath)), "/"), nil } diff --git a/pkg/cmd/browse/browse_test.go b/pkg/cmd/browse/browse_test.go index 6d4476f3a33..c321fbdbb57 100644 --- a/pkg/cmd/browse/browse_test.go +++ b/pkg/cmd/browse/browse_test.go @@ -207,6 +207,29 @@ func TestNewCmdBrowse(t *testing.T) { cli: "de07febc26e19000f8c9e821207f3bc34a3c8038 --commit=12a4", wantsErr: true, }, + { + name: "blame flag", + cli: "main.go --blame", + wants: BrowseOptions{ + BlameFlag: true, + SelectorArg: "main.go", + }, + wantsErr: false, + }, + { + name: "blame flag without file argument", + cli: "--blame", + wantsErr: true, + }, + { + name: "blame flag with line number", + cli: "main.go:312 --blame", + wants: BrowseOptions{ + BlameFlag: true, + SelectorArg: "main.go:312", + }, + wantsErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -239,6 +262,7 @@ func TestNewCmdBrowse(t *testing.T) { assert.Equal(t, tt.wants.SettingsFlag, opts.SettingsFlag) assert.Equal(t, tt.wants.ActionsFlag, opts.ActionsFlag) assert.Equal(t, tt.wants.Commit, opts.Commit) + assert.Equal(t, tt.wants.BlameFlag, opts.BlameFlag) }) } } @@ -595,6 +619,61 @@ func Test_runBrowse(t *testing.T) { expectedURL: "https://github.com/bchadwic/test/tree/trunk/77507cd94ccafcf568f8560cfecde965fcfa63e7.txt", wantsErr: false, }, + { + name: "file with blame flag", + opts: BrowseOptions{ + SelectorArg: "path/to/file.txt", + BlameFlag: true, + }, + baseRepo: ghrepo.New("owner", "repo"), + defaultBranch: "main", + expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt", + wantsErr: false, + }, + { + name: "file with blame flag and line number", + opts: BrowseOptions{ + SelectorArg: "path/to/file.txt:42", + BlameFlag: true, + }, + baseRepo: ghrepo.New("owner", "repo"), + defaultBranch: "main", + expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt#L42", + wantsErr: false, + }, + { + name: "file with blame flag and line range", + opts: BrowseOptions{ + SelectorArg: "path/to/file.txt:10-20", + BlameFlag: true, + }, + baseRepo: ghrepo.New("owner", "repo"), + defaultBranch: "main", + expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt#L10-L20", + wantsErr: false, + }, + { + name: "file with blame flag and branch", + opts: BrowseOptions{ + SelectorArg: "main.go:100", + BlameFlag: true, + Branch: "feature-branch", + }, + baseRepo: ghrepo.New("owner", "repo"), + expectedURL: "https://github.com/owner/repo/blame/feature-branch/main.go#L100", + wantsErr: false, + }, + { + name: "file with blame flag and commit", + opts: BrowseOptions{ + SelectorArg: "src/app.js:50", + BlameFlag: true, + Commit: "abc123", + }, + baseRepo: ghrepo.New("owner", "repo"), + expectedURL: "https://github.com/owner/repo/blame/abc123/src/app.js#L50", + wantsErr: false, + }, } for _, tt := range tests { From 22658208533c56ec894f0a258611651933cea8c1 Mon Sep 17 00:00:00 2001 From: Takeshi Date: Tue, 3 Mar 2026 21:51:18 -0500 Subject: [PATCH 9/9] Combine issue feature detection into a single GraphQL query Merge the Issue_fields and IssueClosedStateReason_enumValues introspection queries into one call to avoid an extra API round-trip on GHES. --- .../featuredetection/feature_detection.go | 32 +++++++------------ .../feature_detection_test.go | 10 ++---- 2 files changed, 13 insertions(+), 29 deletions(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index b162e4c2cf3..e40c134bc7b 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -151,6 +151,11 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { Name string } `graphql:"fields(includeDeprecated: true)"` } `graphql:"Issue: __type(name: \"Issue\")"` + IssueClosedStateReason struct { + EnumValues []struct { + Name string + } `graphql:"enumValues(includeDeprecated: true)"` + } `graphql:"IssueClosedStateReason: __type(name: \"IssueClosedStateReason\")"` } gql := api.NewClientFromHTTP(d.httpClient) @@ -165,27 +170,12 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { } } - if !features.StateReason { - return features, nil - } - - var issueClosedStateReasonFeatureDetection struct { - IssueClosedStateReason struct { - EnumValues []struct { - Name string - } `graphql:"enumValues(includeDeprecated: true)"` - } `graphql:"IssueClosedStateReason: __type(name: \"IssueClosedStateReason\")"` - } - - err = gql.Query(d.host, "IssueClosedStateReason_enumValues", &issueClosedStateReasonFeatureDetection, nil) - if err != nil { - return features, err - } - - for _, enumValue := range issueClosedStateReasonFeatureDetection.IssueClosedStateReason.EnumValues { - if enumValue.Name == "DUPLICATE" { - features.StateReasonDuplicate = true - break + if features.StateReason { + for _, enumValue := range featureDetection.IssueClosedStateReason.EnumValues { + if enumValue.Name == "DUPLICATE" { + features.StateReasonDuplicate = true + break + } } } diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index c1d7b3b4a70..7417cc7d8e8 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -59,10 +59,7 @@ func TestIssueFeatures(t *testing.T) { `query Issue_fields\b`: heredoc.Doc(` { "data": { "Issue": { "fields": [ {"name": "stateReason"} - ] } } } - `), - `query IssueClosedStateReason_enumValues\b`: heredoc.Doc(` - { "data": { "IssueClosedStateReason": { "enumValues": [ + ] }, "IssueClosedStateReason": { "enumValues": [ {"name": "COMPLETED"}, {"name": "NOT_PLANNED"} ] } } } @@ -82,10 +79,7 @@ func TestIssueFeatures(t *testing.T) { `query Issue_fields\b`: heredoc.Doc(` { "data": { "Issue": { "fields": [ {"name": "stateReason"} - ] } } } - `), - `query IssueClosedStateReason_enumValues\b`: heredoc.Doc(` - { "data": { "IssueClosedStateReason": { "enumValues": [ + ] }, "IssueClosedStateReason": { "enumValues": [ {"name": "COMPLETED"}, {"name": "NOT_PLANNED"}, {"name": "DUPLICATE"}