Fix issue develop repeated invocation with named branches

This commit is contained in:
gunadhya 2026-02-09 23:31:22 +05:30
parent a2b8b687c0
commit b38f6772e5
2 changed files with 274 additions and 13 deletions

View file

@ -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)

View file

@ -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"}}}}}`,