Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -651,18 +651,18 @@ function selectTemplate(t: { name: string; source: string }) {
<Label class="text-sm">Ref Type</Label>
<Select.Root type="single" bind:value={refType}>
<Select.Trigger class="h-8 w-full rounded-lg">
{refType === "branch" ? "Branch" : refType === "commit" ? "Commit" : "Pull Request"}
{refType === "branch" ? "Branch" : refType === "commit" ? "Commit" : "Pull / Merge Request"}
</Select.Trigger>
<Select.Content>
<Select.Item value="branch">Branch</Select.Item>
<Select.Item value="commit">Commit</Select.Item>
<Select.Item value="pr">Pull Request</Select.Item>
<Select.Item value="pr">Pull / Merge Request</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label class="text-sm">
{refType === "branch" ? "Branch name" : refType === "commit" ? "Commit SHA" : "PR number"}
{refType === "branch" ? "Branch name" : refType === "commit" ? "Commit SHA" : "PR / MR number"}
</Label>
<Input
placeholder={refType === "branch" ? "main" : refType === "commit" ? "abc123…" : "42"}
Expand Down Expand Up @@ -1022,7 +1022,7 @@ function selectTemplate(t: { name: string; source: string }) {
{#if sourceType === "git" && refValue.trim()}
<div class="flex justify-between gap-3">
<span class="text-muted-foreground">
{refType === "branch" ? "Branch" : refType === "commit" ? "Commit" : "Pull request"}
{refType === "branch" ? "Branch" : refType === "commit" ? "Commit" : "Pull / Merge request"}
</span>
<span class="font-medium truncate">{refValue}</span>
</div>
Expand Down
23 changes: 22 additions & 1 deletion desktop/src/renderer/src/lib/utils/workspace-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,34 @@ describe("buildWorkspaceSource", () => {
expect(out.source).toBe("github.com/org/repo@sha256:abc123")
})

it("git: PR ref appends @pull/N/head", () => {
it("git: PR ref appends @pull/N/head for GitHub", () => {
const out = buildWorkspaceSource(
gitForm({ repoUrl: "github.com/org/repo", refType: "pr", refValue: "42" }),
)
expect(out.source).toBe("github.com/org/repo@pull/42/head")
})

it("git: MR ref appends @merge-requests/N/head for GitLab", () => {
const out = buildWorkspaceSource(
gitForm({ repoUrl: "gitlab.com/org/repo", refType: "pr", refValue: "7125" }),
)
expect(out.source).toBe("gitlab.com/org/repo@merge-requests/7125/head")
})

it("git: MR ref detects self-hosted GitLab by hostname", () => {
const out = buildWorkspaceSource(
gitForm({ repoUrl: "git@gitlab.example.com:org/repo.git", refType: "pr", refValue: "7" }),
)
expect(out.source).toBe("git@gitlab.example.com:org/repo.git@merge-requests/7/head")
})

it("git: PR ref uses pull/N/head when only the owner/path says gitlab", () => {
const out = buildWorkspaceSource(
gitForm({ repoUrl: "git@github.com:gitlab-org/repo.git", refType: "pr", refValue: "7" }),
)
expect(out.source).toBe("git@github.com:gitlab-org/repo.git@pull/7/head")
})

it("git: subpath appends @subpath: after ref", () => {
const out = buildWorkspaceSource(
gitForm({
Expand Down
31 changes: 28 additions & 3 deletions desktop/src/renderer/src/lib/utils/workspace-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,31 @@ export interface WorkspaceSourceResult {
prebuildRepository?: string
}

function refSuffix(refType: GitRefType, refValue: string): string {
// hostFromUrl extracts the hostname from SSH (git@host:path) and URL
// (scheme://[user@]host/path) forms.
function hostFromUrl(repoUrl: string): string {
let s = repoUrl
const scheme = s.indexOf("://")
if (scheme !== -1) s = s.slice(scheme + 3)
const at = s.indexOf("@")
if (at !== -1) s = s.slice(at + 1)
const sep = s.search(/[/:]/)
if (sep !== -1) s = s.slice(0, sep)
return s
}

// GitLab exposes merge requests at merge-requests/N/head; every other host
// uses pull/N/head.
function prRefspec(repoUrl: string, number: string): string {
const segment = /gitlab/i.test(hostFromUrl(repoUrl)) ? "merge-requests" : "pull"
return `@${segment}/${number}/head`
}

function refSuffix(
refType: GitRefType,
refValue: string,
repoUrl: string,
): string {
const value = refValue.trim()
if (!value) return ""
switch (refType) {
Expand All @@ -43,7 +67,7 @@ function refSuffix(refType: GitRefType, refValue: string): string {
case "commit":
return `@sha256:${value}`
case "pr":
return `@pull/${value}/head`
return prRefspec(repoUrl, value)
}
}

Expand Down Expand Up @@ -72,7 +96,8 @@ export function buildWorkspaceSource(
}
}

const source = `${form.repoUrl.trim()}${refSuffix(form.refType, form.refValue)}${subPathSuffix(form.subPath)}`
const repoUrl = form.repoUrl.trim()
const source = `${repoUrl}${refSuffix(form.refType, form.refValue, repoUrl)}${subPathSuffix(form.subPath)}`

return {
source,
Expand Down
24 changes: 17 additions & 7 deletions pkg/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import (

const (
CommitDelimiter string = "@sha256:"
PullRequestReference string = "pull/([0-9]+)/head"
PullRequestReference string = "(?:pull|merge-requests)/([0-9]+)/head"
SubPathDelimiter string = "@subpath:"
repoBaseRegEx string = `((?:(?:https?|git|ssh|file):\/\/)?\/?(?:[^@\/\n]+@)?` +
`(?:[^:\/\n]+)(?:[:\/][^\/\n]+)+(?:\.git)?)`
)

// WARN: Make sure this matches the regex in /desktop/src/views/Workspaces/CreateWorkspace/CreateWorkspaceInput.tsx!
// WARN: Keep these in sync with the ref parsing in UI.
var (
branchRegEx = regexp.MustCompile(`^` + repoBaseRegEx + `@([a-zA-Z0-9\./\-\_]+)$`)
commitRegEx = regexp.MustCompile(
Expand All @@ -36,7 +36,8 @@ var recognizedSchemes = []string{"ssh://", "git@", "http://", "https://", "file:

// NormalizeRepository parses a repository reference into its structured parts.
// Accepts plain URLs, the "git:<url>" workspace-source scheme, and references
// suffixed with @branch, @subpath:<path>, @sha256:<commit>, or @pull/N/head.
// suffixed with @branch, @subpath:<path>, @sha256:<commit>, or a pull/merge
// request ref (@pull/N/head or @merge-requests/N/head).
// Bare host[/path] inputs are upgraded to https://.
func NormalizeRepository(str string) *GitInfo {
str = canonicalizeURL(str)
Expand Down Expand Up @@ -87,14 +88,23 @@ func PingRepository(str string, extraEnv []string) bool {
return At("", WithEnv(extraEnv)).LsRemote(timeoutCtx, str) == nil
}

// GetBranchNameForPR returns the local branch name for a request ref, using the
// provider convention encoded in the ref (PR<n> for GitHub, MR<n> for GitLab).
func GetBranchNameForPR(ref string) string {
regex := regexp.MustCompile(PullRequestReference)
return regex.ReplaceAllString(ref, "PR${1}")
number := prNumber(ref)
if number == "" {
return ref
}
return hostForRef(ref).BranchName(number)
}

// GetIDForPR returns the lowercased request identifier used in workspace IDs.
func GetIDForPR(ref string) string {
regex := regexp.MustCompile(PullRequestReference)
return regex.ReplaceAllString(ref, "pr${1}")
number := prNumber(ref)
if number == "" {
return ref
}
return strings.ToLower(hostForRef(ref).BranchName(number))
}

// GitInfo is the parsed form of a repository reference. Branch, Commit, PR,
Expand Down
12 changes: 12 additions & 0 deletions pkg/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@ var normalizeRepositoryCases = []testCaseNormalizeRepository{
expectedCommit: "",
expectedSubpath: "",
},
{
in: "git@gitlab.com:h3upperbounds/data/data-team.git@merge-requests/7125/head",
expectedRepo: "git@gitlab.com:h3upperbounds/data/data-team.git",
expectedPRReference: "merge-requests/7125/head",
expectedBranch: "",
expectedCommit: "",
expectedSubpath: "",
},
{
in: "github.com/devsy-org/devsy-without-protocol-with-slash.git@subpath:/test/path",
expectedRepo: repoDevsyNoProtoSlash,
Expand Down Expand Up @@ -260,6 +268,10 @@ func TestGetBranchNameForPRReference(t *testing.T) {
in: testPRRef,
expectedBranch: "PR996",
},
{
in: "merge-requests/7125/head",
expectedBranch: "MR7125",
},
{
in: "pull/abc/head",
expectedBranch: "pull/abc/head",
Expand Down
101 changes: 101 additions & 0 deletions pkg/git/host.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package git

import (
"regexp"
"strings"
)

var prNumberRegEx = regexp.MustCompile(`(?:pull|merge-requests)/([0-9]+)/head`)

const (
hostNameGitHub = "github"
hostNameGitLab = "gitlab"
)

// Host is a git provider's convention for referencing pull/merge requests:
// GitHub exposes them at pull/N/head, GitLab at merge-requests/N/head.
type Host struct {
Name string
refPrefix string
branchAbbr string
hostHint string // substring identifying the provider in a repository URL
}

var (
HostGitHub = Host{
Name: hostNameGitHub,
refPrefix: "pull",
branchAbbr: "PR",
hostHint: hostNameGitHub,
}
HostGitLab = Host{
Name: hostNameGitLab,
refPrefix: "merge-requests",
branchAbbr: "MR",
hostHint: hostNameGitLab,
}

knownHosts = []Host{HostGitHub, HostGitLab}
)

func (h Host) Refspec(number string) string {
return h.refPrefix + "/" + number + "/head"
}

func (h Host) BranchName(number string) string {
return h.branchAbbr + number
}

// DetectHost picks the provider from a repository URL, defaulting to GitHub.
func DetectHost(repoURL string) Host {
host := strings.ToLower(hostFromURL(repoURL))
for _, h := range knownHosts {
if strings.Contains(host, h.hostHint) {
return h
}
}
return HostGitHub
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// hostFromURL extracts the hostname from SSH (git@host:path) and URL
// (scheme://[user@]host/path) forms, returning "" when none is present.
func hostFromURL(repoURL string) string {
s := repoURL
if i := strings.Index(s, "://"); i != -1 {
s = s[i+3:]
}
if i := strings.Index(s, "@"); i != -1 {
s = s[i+1:]
}
if i := strings.IndexAny(s, "/:"); i != -1 {
s = s[:i]
}
return s
}

// hostForRef infers the provider from the ref itself.
func hostForRef(ref string) Host {
if strings.Contains(ref, HostGitLab.refPrefix+"/") {
return HostGitLab
}
return HostGitHub
}

func prNumber(ref string) string {
if m := prNumberRegEx.FindStringSubmatch(ref); m != nil {
return m[1]
}
return ""
}

// prCandidates orders the hosts to try for a checkout.
func prCandidates(repoURL string) []Host {
primary := DetectHost(repoURL)
candidates := []Host{primary}
for _, h := range knownHosts {
if h.Name != primary.Name {
candidates = append(candidates, h)
}
}
return candidates
}
50 changes: 50 additions & 0 deletions pkg/git/host_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package git

import (
"testing"

"gotest.tools/assert"
"gotest.tools/assert/cmp"
)

func TestDetectHost(t *testing.T) {
cases := []struct {
url string
want string
}{
{"git@github.com:org/repo.git", hostNameGitHub},
{"https://github.com/org/repo.git", hostNameGitHub},
{"git@gitlab.com:org/repo.git", hostNameGitLab},
{"https://gitlab.example.com/org/repo.git", hostNameGitLab},
// Only the hostname decides: a "gitlab" owner/path on github.com is GitHub.
{"git@github.com:gitlab-org/repo.git", hostNameGitHub},
{"https://github.com/org/gitlab-mirror.git", hostNameGitHub},
{
"https://git.internal.example/org/repo.git",
hostNameGitHub,
}, // unknown host defaults to GitHub
}
for _, c := range cases {
assert.Check(t, cmp.Equal(c.want, DetectHost(c.url).Name), "url=%s", c.url)
}
}

func TestHostRefspecAndBranch(t *testing.T) {
assert.Equal(t, "pull/42/head", HostGitHub.Refspec("42"))
assert.Equal(t, "PR42", HostGitHub.BranchName("42"))
assert.Equal(t, "merge-requests/42/head", HostGitLab.Refspec("42"))
assert.Equal(t, "MR42", HostGitLab.BranchName("42"))
}

func TestPRNumber(t *testing.T) {
assert.Equal(t, "996", prNumber("pull/996/head"))
assert.Equal(t, "7125", prNumber("merge-requests/7125/head"))
assert.Equal(t, "", prNumber("refs/heads/main"))
}

func TestPRCandidatesOrder(t *testing.T) {
got := prCandidates("git@gitlab.com:org/repo.git")
assert.Equal(t, 2, len(got))
assert.Equal(t, hostNameGitLab, got[0].Name) // detected host first
assert.Equal(t, hostNameGitHub, got[1].Name) // fallback
}
Loading
Loading