From 990db1f67656716377763d0ac2075668eb08d9be Mon Sep 17 00:00:00 2001 From: Ilya Kiselev Date: Sat, 21 Mar 2026 00:29:44 +0300 Subject: [PATCH] Add 'copy branch URL to clipboard' feature Adds a keybinding (default: 'y') in the branches panel to copy the branch's URL on the hosting service to the clipboard. Each supported hosting service has a dedicated URL template: - GitHub: /tree/ - GitLab: /-/tree/ - Bitbucket: /branch/ - Azure DevOps: ?version=GB - Bitbucket Server: /browse?at= - Gitea/Codeberg: /src/branch/ Closes #1959 --- pkg/commands/hosting_service/definitions.go | 7 ++ .../hosting_service/hosting_service.go | 14 +++ .../hosting_service/hosting_service_test.go | 93 +++++++++++++++++++ pkg/config/user_config.go | 2 + pkg/gui/controllers/branches_controller.go | 29 ++++++ pkg/gui/controllers/helpers/host_helper.go | 8 ++ pkg/i18n/english.go | 6 ++ 7 files changed, 159 insertions(+) diff --git a/pkg/commands/hosting_service/definitions.go b/pkg/commands/hosting_service/definitions.go index 09fa191c8..452b07572 100644 --- a/pkg/commands/hosting_service/definitions.go +++ b/pkg/commands/hosting_service/definitions.go @@ -21,6 +21,7 @@ var githubServiceDef = ServiceDefinition{ pullRequestURLIntoDefaultBranch: "/compare/{{.From}}?expand=1", pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}?expand=1", commitURL: "/commit/{{.CommitHash}}", + branchURL: "/tree/{{.BranchName}}", urlRegexps: defaultUrlRegexps, repoURLTemplate: defaultRepoURLTemplate, repoNameTemplate: defaultRepoNameTemplate, @@ -31,6 +32,7 @@ var bitbucketServiceDef = ServiceDefinition{ pullRequestURLIntoDefaultBranch: "/pull-requests/new?source={{.From}}&t=1", pullRequestURLIntoTargetBranch: "/pull-requests/new?source={{.From}}&dest={{.To}}&t=1", commitURL: "/commits/{{.CommitHash}}", + branchURL: "/branch/{{.BranchName}}", urlRegexps: []*regexp.Regexp{ regexp.MustCompile(`^(?:https?|ssh)://.*/(?P.*)/(?P.*?)(?:\.git)?$`), regexp.MustCompile(`^.*@.*:/*(?P.*)/(?P.*?)(?:\.git)?$`), @@ -44,6 +46,7 @@ var gitLabServiceDef = ServiceDefinition{ pullRequestURLIntoDefaultBranch: "/-/merge_requests/new?merge_request%5Bsource_branch%5D={{.From}}", pullRequestURLIntoTargetBranch: "/-/merge_requests/new?merge_request%5Bsource_branch%5D={{.From}}&merge_request%5Btarget_branch%5D={{.To}}", commitURL: "/-/commit/{{.CommitHash}}", + branchURL: "/-/tree/{{.BranchName}}", urlRegexps: defaultUrlRegexps, repoURLTemplate: defaultRepoURLTemplate, repoNameTemplate: defaultRepoNameTemplate, @@ -54,6 +57,7 @@ var azdoServiceDef = ServiceDefinition{ pullRequestURLIntoDefaultBranch: "/pullrequestcreate?sourceRef={{.From}}", pullRequestURLIntoTargetBranch: "/pullrequestcreate?sourceRef={{.From}}&targetRef={{.To}}", commitURL: "/commit/{{.CommitHash}}", + branchURL: "?version=GB{{.BranchName}}", urlRegexps: []*regexp.Regexp{ regexp.MustCompile(`^.+@vs-ssh\.visualstudio\.com[:/](?:v3/)?(?P[^/]+)/(?P[^/]+)/(?P[^/]+?)(?:\.git)?$`), regexp.MustCompile(`^git@ssh.dev.azure.com.*/(?P.*)/(?P.*)/(?P.*?)(?:\.git)?$`), @@ -69,6 +73,7 @@ var bitbucketServerServiceDef = ServiceDefinition{ pullRequestURLIntoDefaultBranch: "/pull-requests?create&sourceBranch={{.From}}", pullRequestURLIntoTargetBranch: "/pull-requests?create&targetBranch={{.To}}&sourceBranch={{.From}}", commitURL: "/commits/{{.CommitHash}}", + branchURL: "/browse?at={{.BranchName}}", urlRegexps: []*regexp.Regexp{ regexp.MustCompile(`^ssh://git@.*/(?P.*)/(?P.*?)(?:\.git)?$`), regexp.MustCompile(`^https://.*/scm/(?P.*)/(?P.*?)(?:\.git)?$`), @@ -82,6 +87,7 @@ var giteaServiceDef = ServiceDefinition{ pullRequestURLIntoDefaultBranch: "/compare/{{.From}}", pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}", commitURL: "/commit/{{.CommitHash}}", + branchURL: "/src/branch/{{.BranchName}}", urlRegexps: defaultUrlRegexps, repoURLTemplate: defaultRepoURLTemplate, } @@ -91,6 +97,7 @@ var codebergServiceDef = ServiceDefinition{ pullRequestURLIntoDefaultBranch: "/compare/{{.From}}", pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}", commitURL: "/commit/{{.CommitHash}}", + branchURL: "/src/branch/{{.BranchName}}", urlRegexps: defaultUrlRegexps, repoURLTemplate: defaultRepoURLTemplate, } diff --git a/pkg/commands/hosting_service/hosting_service.go b/pkg/commands/hosting_service/hosting_service.go index ff2641441..62f34d710 100644 --- a/pkg/commands/hosting_service/hosting_service.go +++ b/pkg/commands/hosting_service/hosting_service.go @@ -109,6 +109,15 @@ func (self *HostingServiceMgr) GetServiceInfo() (ServiceInfo, error) { }, nil } +func (self *HostingServiceMgr) GetBranchURL(branchName string) (string, error) { + gitService, err := self.getService() + if err != nil { + return "", err + } + + return gitService.getBranchURL(url.QueryEscape(branchName)), nil +} + func (self *HostingServiceMgr) getService() (*Service, error) { serviceDomain, err := self.getServiceDomain(self.remoteURL) if err != nil { @@ -196,6 +205,7 @@ type ServiceDefinition struct { pullRequestURLIntoTargetBranch string commitURL string urlRegexps []*regexp.Regexp + branchURL string // can expect 'webdomain' to be passed in. Otherwise, you get to pick what we match in the regex repoURLTemplate string @@ -273,6 +283,10 @@ func (self *Service) getCommitURL(commitHash string) string { return self.resolveUrl(self.commitURL, map[string]string{"CommitHash": commitHash}) } +func (self *Service) getBranchURL(branchName string) string { + return self.resolveUrl(self.branchURL, map[string]string{"BranchName": branchName}) +} + func (self *Service) resolveUrl(templateString string, args map[string]string) string { return self.repoURL + utils.ResolvePlaceholderString(templateString, args) } diff --git a/pkg/commands/hosting_service/hosting_service_test.go b/pkg/commands/hosting_service/hosting_service_test.go index f150f22eb..e6519b36d 100644 --- a/pkg/commands/hosting_service/hosting_service_test.go +++ b/pkg/commands/hosting_service/hosting_service_test.go @@ -681,3 +681,96 @@ func TestGetServiceInfo(t *testing.T) { }) } } + +func TestGetBranchURL(t *testing.T) { + type scenario struct { + testName string + branchName string + remoteUrl string + configServiceDomains map[string]string + test func(url string, err error) + } + + scenarios := []scenario{ + { + testName: "Returns branch URL for github (SSH)", + branchName: "feature/my-feature", + remoteUrl: "git@github.com:peter/calculator.git", + test: func(url string, err error) { + assert.NoError(t, err) + assert.Equal(t, "https://github.com/peter/calculator/tree/feature%2Fmy-feature", url) + }, + }, + { + testName: "Returns branch URL for github (HTTPS)", + branchName: "feature/my-feature", + remoteUrl: "https://github.com/peter/calculator.git", + test: func(url string, err error) { + assert.NoError(t, err) + assert.Equal(t, "https://github.com/peter/calculator/tree/feature%2Fmy-feature", url) + }, + }, + { + testName: "Returns branch URL for gitlab", + branchName: "feature/ui", + remoteUrl: "git@gitlab.com:peter/calculator.git", + test: func(url string, err error) { + assert.NoError(t, err) + assert.Equal(t, "https://gitlab.com/peter/calculator/-/tree/feature%2Fui", url) + }, + }, + { + testName: "Returns branch URL for bitbucket", + branchName: "feature/profile-page", + remoteUrl: "git@bitbucket.org:johndoe/social_network.git", + test: func(url string, err error) { + assert.NoError(t, err) + assert.Equal(t, "https://bitbucket.org/johndoe/social_network/branch/feature%2Fprofile-page", url) + }, + }, + { + testName: "Returns branch URL for gitea", + branchName: "main", + remoteUrl: "git@try.gitea.io:johndoe/myrepo.git", + test: func(url string, err error) { + assert.NoError(t, err) + assert.Equal(t, "https://try.gitea.io/johndoe/myrepo/src/branch/main", url) + }, + }, + { + testName: "Returns branch URL for codeberg", + branchName: "develop", + remoteUrl: "git@codeberg.org:johndoe/myrepo.git", + test: func(url string, err error) { + assert.NoError(t, err) + assert.Equal(t, "https://codeberg.org/johndoe/myrepo/src/branch/develop", url) + }, + }, + { + testName: "Escapes reserved URL characters in branch name", + branchName: "feature/issue#42", + remoteUrl: "git@github.com:peter/calculator.git", + test: func(url string, err error) { + assert.NoError(t, err) + assert.Equal(t, "https://github.com/peter/calculator/tree/feature%2Fissue%2342", url) + }, + }, + { + testName: "Returns error for unsupported service", + branchName: "main", + remoteUrl: "git@unknown-host.com:peter/calculator.git", + test: func(url string, err error) { + assert.Error(t, err) + }, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + tr := i18n.EnglishTranslationSet() + log := &fakes.FakeFieldLogger{} + hostingServiceMgr := NewHostingServiceMgr(log, tr, s.remoteUrl, s.configServiceDomains) + s.test(hostingServiceMgr.GetBranchURL(s.branchName)) + }) + } +} diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 30ce0377d..901807885 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -590,6 +590,7 @@ type KeybindingBranchesConfig struct { ViewPullRequestOptions Keybinding `yaml:"viewPullRequestOptions"` OpenPullRequestInBrowser Keybinding `yaml:"openPullRequestInBrowser"` CopyPullRequestURL Keybinding `yaml:"copyPullRequestURL"` + CopyBranchURL Keybinding `yaml:"copyBranchURL"` CheckoutBranchByName Keybinding `yaml:"checkoutBranchByName"` ForceCheckoutBranch Keybinding `yaml:"forceCheckoutBranch"` CheckoutPreviousBranch Keybinding `yaml:"checkoutPreviousBranch"` @@ -1105,6 +1106,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { CreatePullRequest: Keybinding{"o"}, ViewPullRequestOptions: Keybinding{"O"}, OpenPullRequestInBrowser: Keybinding{"G"}, + CopyBranchURL: Keybinding{"y"}, CheckoutBranchByName: Keybinding{"c"}, ForceCheckoutBranch: Keybinding{"F"}, CheckoutPreviousBranch: Keybinding{"-"}, diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index a886a410b..feeacc826 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -100,6 +100,12 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CopyPullRequestURL, }, + { + Keys: opts.GetKeys(opts.Config.Branches.CopyBranchURL), + Handler: self.copyBranchURL, + GetDisabledReason: self.require(self.singleItemSelected()), + Description: self.c.Tr.CopyBranchURL, + }, { Keys: opts.GetKeys(opts.Config.Branches.CheckoutBranchByName), Handler: self.checkoutByName, @@ -533,6 +539,29 @@ func (self *BranchesController) copyPullRequestURL() error { return nil } +func (self *BranchesController) copyBranchURL() error { + branch := self.context().GetSelected() + + branchExistsOnRemote := self.c.Git().Remote.CheckRemoteBranchExists(branch.Name) + + if !branchExistsOnRemote { + return errors.New(self.c.Tr.NoBranchOnRemote) + } + + url, err := self.c.Helpers().Host.GetBranchURL(branch.Name) + if err != nil { + return err + } + self.c.LogAction(self.c.Tr.Actions.CopyBranchURL) + if err := self.c.OS().CopyToClipboard(url); err != nil { + return err + } + + self.c.Toast(self.c.Tr.BranchURLCopiedToClipboard) + + return nil +} + func (self *BranchesController) forceCheckout() error { branch := self.context().GetSelected() message := self.c.Tr.SureForceCheckout diff --git a/pkg/gui/controllers/helpers/host_helper.go b/pkg/gui/controllers/helpers/host_helper.go index 42115e86f..1559ed3e6 100644 --- a/pkg/gui/controllers/helpers/host_helper.go +++ b/pkg/gui/controllers/helpers/host_helper.go @@ -34,6 +34,14 @@ func (self *HostHelper) GetCommitURL(commitHash string) (string, error) { return mgr.GetCommitURL(commitHash) } +func (self *HostHelper) GetBranchURL(branchName string) (string, error) { + mgr, err := self.getHostingServiceMgr() + if err != nil { + return "", err + } + return mgr.GetBranchURL(branchName) +} + // getting this on every request rather than storing it in state in case our remoteURL changes // from one invocation to the next. func (self *HostHelper) getHostingServiceMgr() (*hosting_service.HostingServiceMgr, error) { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 69ea7012f..e32cef8de 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -287,6 +287,7 @@ type TranslationSet struct { CopyPullRequestURL string OpenPullRequestInBrowser string NoPullRequestForBranch string + CopyBranchURL string NoBranchOnRemote string Fetch string FetchTooltip string @@ -752,6 +753,7 @@ type TranslationSet struct { SuggestionsSubtitle string ExtrasTitle string PullRequestURLCopiedToClipboard string + BranchURLCopiedToClipboard string CommitDiffCopiedToClipboard string CommitURLCopiedToClipboard string CommitMessageCopiedToClipboard string @@ -1111,6 +1113,7 @@ type Actions struct { Undo string Redo string CopyPullRequestURL string + CopyBranchURL string OpenMergeTool string OpenCommitInBrowser string OpenPullRequest string @@ -1435,6 +1438,7 @@ func EnglishTranslationSet() *TranslationSet { CopyPullRequestURL: `Copy pull request URL to clipboard`, OpenPullRequestInBrowser: `Open pull request in browser`, NoPullRequestForBranch: `No pull request found for this branch`, + CopyBranchURL: `Copy branch URL to clipboard`, NoBranchOnRemote: `This branch doesn't exist on remote. You need to push it to remote first.`, Fetch: `Fetch`, FetchTooltip: "Fetch changes from remote.", @@ -1904,6 +1908,7 @@ func EnglishTranslationSet() *TranslationSet { SuggestionsSubtitle: "(press %s to delete, %s to edit)", ExtrasTitle: "Command log", PullRequestURLCopiedToClipboard: "Pull request URL copied to clipboard", + BranchURLCopiedToClipboard: "Branch URL copied to clipboard", CommitDiffCopiedToClipboard: "Commit diff copied to clipboard", CommitURLCopiedToClipboard: "Commit URL copied to clipboard", CommitMessageCopiedToClipboard: "Commit message copied to clipboard", @@ -2220,6 +2225,7 @@ func EnglishTranslationSet() *TranslationSet { Undo: "Undo", Redo: "Redo", CopyPullRequestURL: "Copy pull request URL", + CopyBranchURL: "Copy branch URL", OpenMergeTool: "Open merge tool", OpenCommitInBrowser: "Open commit in browser", OpenPullRequest: "Open pull request in browser",