From 1505df2bb1d98336d4c0b688207048aa76fce922 Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 22:38:39 +0200 Subject: [PATCH] feat(app): parse issue, pull request and run references resolveRef accepts N, #N, [HOST/]OWNER/REPO#N and issue, pull request or run URLs; a URL or qualified number selects its repository for the invocation and must agree with --repo. resolveRefs does the same for several references. pullSelector also accepts a head branch or OWNER:BRANCH and, without an argument, finds the pull request of the checked-out branch: the one pr checkout recorded in branch..tea-pr, else the open pull request whose head is the branch's upstream in the upstream remote's repository, else one whose head has the local branch's name, else N of a pr/N branch. This lets pr commands find the pull request of a branch pr checkout created. target.expandMe resolves @me in user-valued flags to the login tea acts as, and git.CurrentBranchConfig reads the branch configuration involved. Signed-off-by: bircni --- internal/app/ref.go | 307 ++++++++++++++++++++++++++++++++++++ internal/app/ref_test.go | 213 +++++++++++++++++++++++++ internal/git/branch.go | 60 +++++++ internal/git/branch_test.go | 41 +++++ 4 files changed, 621 insertions(+) create mode 100644 internal/app/ref.go create mode 100644 internal/app/ref_test.go create mode 100644 internal/git/branch.go create mode 100644 internal/git/branch_test.go diff --git a/internal/app/ref.go b/internal/app/ref.go new file mode 100644 index 00000000..ef18437a --- /dev/null +++ b/internal/app/ref.go @@ -0,0 +1,307 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package app + +import ( + "cmp" + "context" + "errors" + "fmt" + "net/url" + "slices" + "strconv" + "strings" + + "gitea.dev/tea/internal/clierr" + "gitea.dev/tea/internal/git" + "gitea.dev/tea/internal/services" + "gitea.dev/tea/internal/settings" + "github.com/spf13/cobra" +) + +// refKind is what a reference argument addresses, which decides the URLs it accepts. +type refKind int + +const ( + // issueRef is an issue or pull request number; it accepts issue and pull request URLs. + issueRef refKind = iota + // pullRef is a pull request number; it accepts pull request and issue URLs. + pullRef + // runRef is an Actions run ID; it accepts run and job URLs. + runRef +) + +// ref is a parsed reference argument. +type ref struct { + Number int64 + // Job is the job of a run URL ending in /jobs/M, else 0. + Job int64 + // Repo is the repository the reference names, as a repository argument (a URL, + // [HOST/]OWNER/REPO or NAME); it is empty for N and #N. + Repo string +} + +// errNotRef marks an argument that is not a number, URL or OWNER/REPO#N, such as a branch name. +var errNotRef = errors.New("not a reference") + +// parseRef parses N, #N, [HOST/]OWNER/REPO#N, or a URL of what kind addresses: +// https://HOST/OWNER/REPO/(issues|pulls)/N[#...] for issues and pull requests, and +// https://HOST/OWNER/REPO/actions/runs/N[/jobs/M] for runs. +func parseRef(raw string, kind refKind) (ref, error) { + raw = strings.TrimSpace(raw) + if strings.Contains(raw, "://") { + return parseRefURL(raw, kind) + } + repo, value, hashed := strings.Cut(raw, "#") + if !hashed { + repo, value = "", raw + } + n, err := strconv.ParseInt(value, 10, 64) + if err != nil && !hashed { + return ref{}, errNotRef + } + if err != nil || n < 1 || (repo != "" && kind == runRef) { + return ref{}, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid %s %q: use %s", kind.noun(), raw, kind.forms())) + } + return ref{Number: n, Repo: repo}, nil +} + +func parseRefURL(raw string, kind refKind) (ref, error) { + invalid := clierr.Wrap(clierr.Usage, fmt.Errorf("%q is not %s URL; use %s", raw, kind.article(), kind.forms())) + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil { + return ref{}, invalid + } + segments := strings.Split(strings.Trim(u.Path, "/"), "/") + positive := func(i int) int64 { + if i >= len(segments) { + return 0 + } + n, err := strconv.ParseInt(segments[i], 10, 64) + if err != nil || n < 1 { + return 0 + } + return n + } + // The repository is the path before the marker, so instances below a base path work too. + for i := 2; i < len(segments); i++ { + result := ref{} + switch { + case kind == runRef && segments[i] == "actions" && i+2 < len(segments) && segments[i+1] == "runs" && positive(i+2) > 0: + result.Number = positive(i + 2) + if i+4 < len(segments) && segments[i+3] == "jobs" { + result.Job = positive(i + 4) + } + case kind != runRef && (segments[i] == "issues" || segments[i] == "pulls") && positive(i+1) > 0: + result.Number = positive(i + 1) + default: + continue + } + repository := url.URL{Scheme: u.Scheme, Host: u.Host, Path: "/" + strings.Join(segments[:i], "/")} + result.Repo = repository.String() + return result, nil + } + return ref{}, invalid +} + +func (k refKind) noun() string { + switch k { + case pullRef: + return "pull request" + case runRef: + return "run" + } + return "issue" +} + +// article returns noun with its indefinite article, such as "an issue". +func (k refKind) article() string { + if k == issueRef { + return "an issue" + } + return "a " + k.noun() +} + +// forms lists the reference forms of kind for error messages. +func (k refKind) forms() string { + if k == runRef { + return "a run ID or URL" + } + return "a number, #number, OWNER/REPO#number or URL" +} + +// resolveRef resolves raw, an issue, pull request or run reference, and the repository it +// belongs to. A URL or OWNER/REPO#N selects that repository for this invocation and must +// agree with --repo; N and #N address the repository tea targets otherwise. +func (a *application) resolveRef(cmd *cobra.Command, raw string, kind refKind) (target, int64, error) { + t, numbers, err := a.resolveRefs(cmd, []string{raw}, kind) + if err != nil { + return t, 0, err + } + return t, numbers[0], nil +} + +// resolveRefs is resolveRef for several references, which must name the same repository and +// distinct numbers. +func (a *application) resolveRefs(cmd *cobra.Command, raws []string, kind refKind) (target, []int64, error) { + numbers := make([]int64, len(raws)) + var selected ref + source := "" + for i, raw := range raws { + parsed, err := parseRef(raw, kind) + if errors.Is(err, errNotRef) { + return target{}, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid %s %q: use %s", kind.noun(), raw, kind.forms())) + } + if err != nil { + return target{}, nil, err + } + if slices.Contains(numbers[:i], parsed.Number) { + return target{}, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("#%d is selected more than once; supply unique numbers", parsed.Number)) + } + numbers[i] = parsed.Number + if parsed.Repo == "" { + continue + } + if selected.Repo != "" && !a.sameRepository(selected.Repo, parsed.Repo) { + return target{}, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("%s and %s are in different repositories; run one command per repository", source, raw)) + } + selected, source = parsed, raw + } + t, err := a.resolveRequest(cmd.Context(), targetRequest{requireRepo: true, authenticate: true, repo: selected.Repo, repoSource: source}) + return t, numbers, err +} + +// sameRepository reports whether two repository arguments name the same repository. +func (a *application) sameRepository(first, second string) bool { + s, err := a.deps.Settings.Load() + if err != nil { + return strings.EqualFold(first, second) + } + one, oneErr := settings.ParseRepository(first, s.Hosts.Hosts) + two, twoErr := settings.ParseRepository(second, s.Hosts.Hosts) + return oneErr == nil && twoErr == nil && strings.EqualFold(one.Slug(), two.Slug()) && (one.Host == "" || two.Host == "" || one.Host == two.Host) +} + +// pullSelector resolves the pull request a pr command addresses: N, #N, OWNER/REPO#N, a pull +// request URL, a head branch, OWNER:BRANCH, or, without an argument, the pull request of the +// checked-out branch. +func (a *application) pullSelector(cmd *cobra.Command, args []string) (target, int64, error) { + if len(args) == 0 || args[0] == "" { + return a.currentPull(cmd.Context()) + } + raw := args[0] + if _, err := parseRef(raw, pullRef); !errors.Is(err, errNotRef) { + return a.resolveRef(cmd, raw, pullRef) + } + t, err := a.resolve(cmd.Context(), true) + if err != nil { + return t, 0, err + } + owner, branch, qualified := strings.Cut(raw, ":") + if !qualified { + owner, branch = "", raw + } + pulls, err := (services.Pulls{Client: t.client, Repository: t.Repo}).ListOpen(cmd.Context()) + if err != nil { + return t, 0, err + } + n, err := pullForBranch(pulls, owner, branch, t.Repo) + if err != nil { + return t, 0, err + } + if n == 0 { + return t, 0, fmt.Errorf("no open pull request for branch %q in %s; pass a number, URL or branch", raw, t.Repo) + } + return t, n, nil +} + +// currentPull finds the pull request of the checked-out branch: the one pr checkout recorded, +// else the open pull request whose head is the branch's upstream (and the upstream remote's +// owner), else one whose head has the local branch's name, else N from a pr/N branch. +func (a *application) currentPull(ctx context.Context) (target, int64, error) { + branch, err := git.CurrentBranchConfig(ctx, a.gitProbe(), a.options.cwd) + if err != nil { + return target{}, 0, clierr.Wrap(clierr.Usage, errors.New("not on a branch; pass a pull request number, URL or branch")) + } + t, err := a.resolve(ctx, true) + if err != nil { + return t, 0, err + } + if branch.PullNumber > 0 && (branch.BaseRemote == "" || a.remoteOwns(ctx, t, branch.BaseRemote)) { + return t, branch.PullNumber, nil + } + pulls, err := (services.Pulls{Client: t.client, Repository: t.Repo}).ListOpen(ctx) + if err != nil { + return t, 0, err + } + if branch.Merge != "" { + owner := "" + if remoteURL, err := a.gitProbe().Output(ctx, a.options.cwd, false, "remote", "get-url", branch.Remote); err == nil { + if _, repo, err := settings.RepositoryURL(remoteURL, t.hosts()); err == nil { + owner, _, _ = strings.Cut(repo, "/") + } + } + if n, err := pullForBranch(pulls, owner, branch.Merge, t.Repo); n > 0 || err != nil { + return t, n, err + } + } + if n, err := pullForBranch(pulls, "", branch.Name, t.Repo); n > 0 || err != nil { + return t, n, err + } + if number, ok := strings.CutPrefix(branch.Name, "pr/"); ok { + if n, err := strconv.ParseInt(number, 10, 64); err == nil && n > 0 { + return t, n, nil + } + } + return t, 0, fmt.Errorf("no open pull request for branch %q in %s; pass a number, URL or branch", branch.Name, t.Repo) +} + +// remoteOwns reports whether the Git remote named remote points at the target's repository. +func (a *application) remoteOwns(ctx context.Context, t target, remote string) bool { + raw, err := a.gitProbe().Output(ctx, a.options.cwd, false, "remote", "get-url", remote) + return err == nil && t.ownsRemote(raw) +} + +// pullForBranch returns the number of the open pull request whose head is branch in a +// repository of owner (any owner when empty), 0 when none is, and an error when several are. +func pullForBranch(pulls []services.Pull, owner, branch, repo string) (int64, error) { + var matches []string + var number int64 + for _, pull := range pulls { + headOwner, _, _ := strings.Cut(pull.HeadRepo, "/") + if pull.Head != branch || (owner != "" && !strings.EqualFold(headOwner, owner)) { + continue + } + matches = append(matches, "#"+strconv.FormatInt(pull.Number, 10)) + number = pull.Number + } + if len(matches) > 1 { + return 0, fmt.Errorf("branch %q has several open pull requests in %s (%s); pass a number", branch, repo, strings.Join(matches, ", ")) + } + return number, nil +} + +// login returns the account tea acts as: the --sudo user, else the authenticated account. +func (t target) login() string { + if t.client != nil && t.client.Sudo != "" { + return t.client.Sudo + } + return t.User +} + +// expandMe returns the login tea acts as for "@me" in a user-valued flag such as --assignee, +// and value unchanged otherwise. +func (t target) expandMe(value string) string { + if strings.TrimSpace(value) == "@me" { + return cmp.Or(t.login(), value) + } + return value +} + +// expandMeAll applies expandMe to every entry of a user-list flag, in place. +func (t target) expandMeAll(values []string) { + for i, value := range values { + values[i] = t.expandMe(value) + } +} diff --git a/internal/app/ref_test.go b/internal/app/ref_test.go new file mode 100644 index 00000000..60352a3c --- /dev/null +++ b/internal/app/ref_test.go @@ -0,0 +1,213 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package app + +import ( + "context" + "net/http" + "testing" + + "gitea.dev/tea/internal/clierr" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestParseRefAcceptsNumbersQualifiedNumbersAndURLs(t *testing.T) { + for _, tc := range []struct { + raw string + kind refKind + want ref + err string + }{ + {raw: "12", want: ref{Number: 12}}, + {raw: "#12", want: ref{Number: 12}}, + {raw: " 12 ", want: ref{Number: 12}}, + {raw: "alice/project#12", want: ref{Number: 12, Repo: "alice/project"}}, + {raw: "localhost:3001/alice/project#12", want: ref{Number: 12, Repo: "localhost:3001/alice/project"}}, + {raw: "project#12", want: ref{Number: 12, Repo: "project"}}, + {raw: "https://gitea.com/alice/project/issues/3", want: ref{Number: 3, Repo: "https://gitea.com/alice/project"}}, + {raw: "https://gitea.com/alice/project/issues/3#issuecomment-79", want: ref{Number: 3, Repo: "https://gitea.com/alice/project"}}, + {raw: "http://localhost:3001/base/alice/project/pulls/7/files?style=split", kind: pullRef, want: ref{Number: 7, Repo: "http://localhost:3001/base/alice/project"}}, + {raw: "https://gitea.com/alice/issues/issues/4", want: ref{Number: 4, Repo: "https://gitea.com/alice/issues"}}, + {raw: "https://gitea.com/alice/project/pulls/7", kind: issueRef, want: ref{Number: 7, Repo: "https://gitea.com/alice/project"}}, + {raw: "https://gitea.com/alice/project/actions/runs/34", kind: runRef, want: ref{Number: 34, Repo: "https://gitea.com/alice/project"}}, + {raw: "https://gitea.com/alice/project/actions/runs/34/jobs/2", kind: runRef, want: ref{Number: 34, Job: 2, Repo: "https://gitea.com/alice/project"}}, + {raw: "#34", kind: runRef, want: ref{Number: 34}}, + {raw: "0", err: `invalid issue "0": use a number, #number, OWNER/REPO#number or URL`}, + {raw: "#abc", kind: pullRef, err: `invalid pull request "#abc"`}, + {raw: "alice/project#", err: `invalid issue "alice/project#"`}, + {raw: "alice/project#3", kind: runRef, err: `invalid run "alice/project#3": use a run ID or URL`}, + {raw: "https://gitea.com/alice/project", err: `"https://gitea.com/alice/project" is not an issue URL`}, + {raw: "https://gitea.com/alice/project/issues/3", kind: runRef, err: "is not a run URL"}, + {raw: "ftp://gitea.com/alice/project/issues/3", err: "is not an issue URL"}, + } { + got, err := parseRef(tc.raw, tc.kind) + if tc.err != "" { + require.ErrorContains(t, err, tc.err, tc.raw) + require.Equal(t, clierr.Usage, clierr.ExitCode(err), tc.raw) + continue + } + require.NoError(t, err, tc.raw) + require.Equal(t, tc.want, got, tc.raw) + } + for _, branch := range []string{"feature/login", "bob:fix", "main"} { + _, err := parseRef(branch, pullRef) + require.ErrorIs(t, err, errNotRef, branch) + } +} + +// refApplication returns an application authenticated against a server listing the open pull +// requests #3 (feat/one in alice/project), #4 (feat/two), #17 (fix in bob/project) and two +// pull requests from branch dup. +func refApplication(t *testing.T, gitResults map[string]string) (*application, string) { + t.Helper() + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/repos/alice/project/pulls", r.URL.Path) + if r.URL.Query().Get("page") != "1" { + _, _ = w.Write([]byte(`[]`)) + return + } + _, _ = w.Write([]byte(`[ + {"number":3,"head":{"ref":"feat/one","repo":{"full_name":"alice/project"}}}, + {"number":4,"head":{"ref":"feat/two","repo":{"full_name":"alice/project"}}}, + {"number":17,"head":{"ref":"fix","repo":{"full_name":"bob/project"}}}, + {"number":20,"head":{"ref":"dup","repo":{"full_name":"alice/project"}}}, + {"number":21,"head":{"ref":"dup","repo":{"full_name":"carol/project"}}} + ]`)) + })) + t.Cleanup(server.Close) + deps, _, _ := testDeps(t) + deps.Getenv = serverEnv(server.URL, map[string]string{"TEA_REPO": "alice/project"}) + results := map[string]string{ + "remote get-url origin": server.URL + "/alice/project.git", + "remote get-url pulls/bob/project": server.URL + "/bob/project.git", + "remote get-url fork": server.URL + "/bob/project.git", + "remote get-url pulls/carol/project": server.URL + "/carol/project.git", + } + for key, value := range gitResults { + results[key] = value + } + deps.Git = &fakeGit{results: results} + return &application{deps: deps}, server.URL +} + +func commandContext() *cobra.Command { + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + return cmd +} + +func TestPullSelectorDefaultsToTheCheckedOutBranch(t *testing.T) { + const head = "symbolic-ref --short HEAD" + branch := func(name string, keys map[string]string) map[string]string { + results := map[string]string{head: name} + for key, value := range keys { + results["config --get branch."+name+"."+key] = value + } + return results + } + for _, tc := range []struct { + name string + git map[string]string + want int64 + err string + }{ + {"recorded by pr checkout", branch("pr/17", map[string]string{"tea-pr": "17", "tea-base-remote": "origin", "remote": "pulls/bob/project", "merge": "refs/heads/fix"}), 17, ""}, + {"head branch name kept by pr checkout", branch("fix", map[string]string{"tea-pr": "17", "tea-base-remote": "origin"}), 17, ""}, + {"recorded for another repository", branch("pr/9", map[string]string{"tea-pr": "9", "tea-base-remote": "fork", "remote": "fork", "merge": "refs/heads/fix"}), 17, ""}, + {"upstream branch and owner", branch("local", map[string]string{"remote": "origin", "merge": "refs/heads/feat/one"}), 3, ""}, + {"upstream owner tells forks apart", branch("dup", map[string]string{"remote": "pulls/carol/project", "merge": "refs/heads/dup"}), 21, ""}, + {"local branch name", branch("feat/two", nil), 4, ""}, + {"pr/N branch", branch("pr/99", nil), 99, ""}, + {"no pull request", branch("main", nil), 0, `no open pull request for branch "main" in alice/project; pass a number, URL or branch`}, + {"ambiguous branch", branch("dup", nil), 0, `branch "dup" has several open pull requests in alice/project (#20, #21); pass a number`}, + {"detached", map[string]string{}, 0, "not on a branch; pass a pull request number, URL or branch"}, + } { + t.Run(tc.name, func(t *testing.T) { + a, _ := refApplication(t, tc.git) + _, n, err := a.pullSelector(commandContext(), nil) + if tc.err != "" { + require.EqualError(t, err, tc.err) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, n) + }) + } +} + +func TestPullSelectorAcceptsNumbersURLsAndBranches(t *testing.T) { + a, serverURL := refApplication(t, nil) + for _, tc := range []struct { + arg string + want int64 + err string + }{ + {"5", 5, ""}, + {"#5", 5, ""}, + {serverURL + "/alice/project/pulls/5", 5, ""}, + {"alice/project#5", 5, ""}, + {"feat/one", 3, ""}, + {"bob:fix", 17, ""}, + {"alice:fix", 0, `no open pull request for branch "alice:fix" in alice/project; pass a number, URL or branch`}, + {"carol:dup", 21, ""}, + {"dup", 0, "several open pull requests"}, + {serverURL + "/alice/project", 0, "is not a pull request URL"}, + } { + t.Run(tc.arg, func(t *testing.T) { + resolved, n, err := a.pullSelector(commandContext(), []string{tc.arg}) + if tc.err != "" { + require.ErrorContains(t, err, tc.err) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, n) + require.Equal(t, "alice/project", resolved.Repo) + }) + } +} + +func TestResolveRefSelectsTheReferencedRepository(t *testing.T) { + a, serverURL := refApplication(t, nil) + resolved, n, err := a.resolveRef(commandContext(), serverURL+"/bob/tool/issues/8", issueRef) + require.NoError(t, err) + require.Equal(t, int64(8), n) + require.Equal(t, "bob/tool", resolved.Repo) + require.Equal(t, serverURL, resolved.Host) + + resolved, numbers, err := a.resolveRefs(commandContext(), []string{"bob/tool#1", "#2", serverURL + "/bob/tool/issues/3"}, issueRef) + require.NoError(t, err) + require.Equal(t, []int64{1, 2, 3}, numbers) + require.Equal(t, "bob/tool", resolved.Repo) + + _, _, err = a.resolveRefs(commandContext(), []string{"bob/tool#1", "alice/project#2"}, issueRef) + require.EqualError(t, err, "bob/tool#1 and alice/project#2 are in different repositories; run one command per repository") + _, _, err = a.resolveRefs(commandContext(), []string{"1", "#1"}, issueRef) + require.EqualError(t, err, "#1 is selected more than once; supply unique numbers") + _, _, err = a.resolveRef(commandContext(), "feature", issueRef) + require.EqualError(t, err, `invalid issue "feature": use a number, #number, OWNER/REPO#number or URL`) + + a.options.repo = "BOB/tool" + resolved, _, err = a.resolveRef(commandContext(), "bob/tool#1", issueRef) + require.NoError(t, err, "the same repository as --repo") + require.Equal(t, "bob/tool", resolved.Repo) + a.options.repo = "bob/other" + _, _, err = a.resolveRef(commandContext(), serverURL+"/bob/tool/issues/1", issueRef) + require.Equal(t, clierr.Usage, clierr.ExitCode(err)) + require.EqualError(t, err, serverURL+"/bob/tool/issues/1 points to bob/tool but --repo is bob/other") +} + +func TestExpandMeUsesTheLoginTeaActsAs(t *testing.T) { + alice := target{User: "alice"} + require.Equal(t, "alice", alice.expandMe("@me")) + require.Equal(t, "bob", alice.expandMe("bob")) + assignees := []string{"@me", "carol"} + alice.expandMeAll(assignees) + require.Equal(t, []string{"alice", "carol"}, assignees) + a, _ := refApplication(t, nil) + a.options.sudo = "bob" + resolved, err := a.resolve(context.Background(), true) + require.NoError(t, err) + require.Equal(t, "bob", resolved.expandMe("@me")) +} diff --git a/internal/git/branch.go b/internal/git/branch.go new file mode 100644 index 00000000..aed76525 --- /dev/null +++ b/internal/git/branch.go @@ -0,0 +1,60 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "errors" + "strconv" + "strings" +) + +// ErrNotOnBranch reports a detached HEAD or a directory outside a checkout. +var ErrNotOnBranch = errors.New("not on a branch") + +// BranchConfig is the configuration of a local branch that locates its pull request. +type BranchConfig struct { + // Name is the local branch. + Name string + // Remote and Merge are the remote and branch the branch tracks, Merge without refs/heads/; + // both are empty for a branch that tracks nothing or another local branch. + Remote, Merge string + // PullNumber and BaseRemote are what pr checkout recorded: the pull request the branch was + // checked out from and the remote of that pull request's base repository. + PullNumber int64 + BaseRemote string +} + +// CurrentBranchConfig reads the configuration of the checked-out branch, failing with +// ErrNotOnBranch when HEAD is detached or dir is not a checkout. +func CurrentBranchConfig(ctx context.Context, runner Runner, dir string) (BranchConfig, error) { + name, err := runner.Output(ctx, dir, false, "symbolic-ref", "--short", "HEAD") + if err != nil || name == "" { + return BranchConfig{}, ErrNotOnBranch + } + return ReadBranchConfig(ctx, runner, dir, name), nil +} + +// ReadBranchConfig reads branch..remote, .merge and the keys pr checkout records. +// Missing or malformed keys stay empty. +func ReadBranchConfig(ctx context.Context, runner Runner, dir, name string) BranchConfig { + get := func(key string) string { + value, err := runner.Output(ctx, dir, false, "config", "--get", "branch."+name+"."+key) + if err != nil { + return "" + } + return value + } + config := BranchConfig{Name: name, Remote: get("remote"), BaseRemote: get(pullBaseRemoteKey)} + if config.Remote == "." { + config.Remote = "" + } + if config.Remote != "" { + config.Merge = strings.TrimPrefix(get("merge"), "refs/heads/") + } + if n, err := strconv.ParseInt(get(pullNumberKey), 10, 64); err == nil && n > 0 { + config.PullNumber = n + } + return config +} diff --git a/internal/git/branch_test.go b/internal/git/branch_test.go new file mode 100644 index 00000000..7596dc8d --- /dev/null +++ b/internal/git/branch_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCurrentBranchConfig(t *testing.T) { + const head = "symbolic-ref --short HEAD" + for _, test := range []struct { + name string + runner discoverRunner + want BranchConfig + err error + }{ + {name: "detached", runner: discoverRunner{}, err: ErrNotOnBranch}, + {name: "untracked", runner: discoverRunner{head: "topic"}, want: BranchConfig{Name: "topic"}}, + {name: "tracking", runner: discoverRunner{ + head: "feat/one", "config --get branch.feat/one.remote": "origin", "config --get branch.feat/one.merge": "refs/heads/feat/one", + }, want: BranchConfig{Name: "feat/one", Remote: "origin", Merge: "feat/one"}}, + {name: "local upstream", runner: discoverRunner{ + head: "topic", "config --get branch.topic.remote": ".", "config --get branch.topic.merge": "refs/heads/main", + }, want: BranchConfig{Name: "topic"}}, + {name: "pr checkout", runner: discoverRunner{ + head: "pr/17", "config --get branch.pr/17.remote": "pulls/bob/project", "config --get branch.pr/17.merge": "refs/heads/bob/fix", + "config --get branch.pr/17.tea-pr": "17", "config --get branch.pr/17.tea-base-remote": "origin", + }, want: BranchConfig{Name: "pr/17", Remote: "pulls/bob/project", Merge: "bob/fix", PullNumber: 17, BaseRemote: "origin"}}, + {name: "malformed pull number", runner: discoverRunner{head: "x", "config --get branch.x.tea-pr": "seventeen"}, want: BranchConfig{Name: "x"}}, + } { + t.Run(test.name, func(t *testing.T) { + config, err := CurrentBranchConfig(context.Background(), test.runner, "/work") + require.ErrorIs(t, err, test.err) + require.Equal(t, test.want, config) + }) + } +}