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/<branch>
- GitLab:           /-/tree/<branch>
- Bitbucket:        /branch/<branch>
- Azure DevOps:     ?version=GB<branch>
- Bitbucket Server: /browse?at=<branch>
- Gitea/Codeberg:   /src/branch/<branch>

Closes #1959
This commit is contained in:
Ilya Kiselev 2026-03-21 00:29:44 +03:00
parent e59c1d1cb7
commit 990db1f676
7 changed files with 159 additions and 0 deletions

View file

@ -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<owner>.*)/(?P<repo>.*?)(?:\.git)?$`),
regexp.MustCompile(`^.*@.*:/*(?P<owner>.*)/(?P<repo>.*?)(?:\.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<org>[^/]+)/(?P<project>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$`),
regexp.MustCompile(`^git@ssh.dev.azure.com.*/(?P<org>.*)/(?P<project>.*)/(?P<repo>.*?)(?:\.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<project>.*)/(?P<repo>.*?)(?:\.git)?$`),
regexp.MustCompile(`^https://.*/scm/(?P<project>.*)/(?P<repo>.*?)(?:\.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,
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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