From 3b38459082b6a30635f99915ab20a6115c82f1f4 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Wed, 14 Oct 2026 08:10:08 +0200 Subject: [PATCH 1/5] fix: remote visit URL for ssh:// and non-git@ SSH remotes - Support `ssh://[user@]host[:port]/path` and `user@host:path` remotes (not only `git@host:path.git`) - Map Azure DevOps SSH remotes (`v3/{org}/{project}/{repo}`) to their web URL - Restore the HTTPS probe for SSH hosts (`HTTPSValidator.Add` was no longer called since 582c1a22) and treat well-known hosts as HTTPS Co-Authored-By: Claude Opus 5.5 --- src/Commands/QueryRemotes.cs | 3 ++ src/Models/HTTPSValidator.cs | 22 ++++++++---- src/Models/Remote.cs | 68 ++++++++++++++++++++++++++++++++---- 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/Commands/QueryRemotes.cs b/src/Commands/QueryRemotes.cs index 6adff9b36d..96e74141b1 100644 --- a/src/Commands/QueryRemotes.cs +++ b/src/Commands/QueryRemotes.cs @@ -66,6 +66,9 @@ public QueryRemotes(string repo) DisableAutoFetch = disableAutoFetchRemotes.Contains(name) }; + if (Models.Remote.TryGetSSHHost(url, out var host)) + Models.HTTPSValidator.Add(host); + remotes.Add(r); } diff --git a/src/Models/HTTPSValidator.cs b/src/Models/HTTPSValidator.cs index 014207c21c..253e8634fa 100644 --- a/src/Models/HTTPSValidator.cs +++ b/src/Models/HTTPSValidator.cs @@ -21,13 +21,7 @@ public static void Add(string host) _hosts.Add(host, true); // Well-known hosts always support HTTPS - if (host.Contains("github.com", StringComparison.Ordinal) || - host.Contains("gitlab", StringComparison.Ordinal) || - host.Contains("azure.com", StringComparison.Ordinal) || - host.Equals("gitee.com", StringComparison.Ordinal) || - host.Equals("bitbucket.org", StringComparison.Ordinal) || - host.Equals("gitea.org", StringComparison.Ordinal) || - host.Equals("gitcode.com", StringComparison.Ordinal)) + if (IsWellKnownHost(host)) return; } @@ -67,6 +61,20 @@ public static void Add(string host) }); } + public static bool IsWellKnownHost(string host) + { + return host.Contains("github.com", StringComparison.Ordinal) || + host.Contains("gitlab", StringComparison.Ordinal) || + host.Contains("azure.com", StringComparison.Ordinal) || + host.Contains("visualstudio.com", StringComparison.Ordinal) || + host.Equals("gitee.com", StringComparison.Ordinal) || + host.Equals("bitbucket.org", StringComparison.Ordinal) || + host.Equals("gitea.org", StringComparison.Ordinal) || + host.Equals("gitea.com", StringComparison.Ordinal) || + host.Equals("codeberg.org", StringComparison.Ordinal) || + host.Equals("gitcode.com", StringComparison.Ordinal); + } + public static bool IsSupported(string host) { lock (_syncLock) diff --git a/src/Models/Remote.cs b/src/Models/Remote.cs index ab16874d26..f080148a0f 100644 --- a/src/Models/Remote.cs +++ b/src/Models/Remote.cs @@ -19,8 +19,11 @@ public partial class Remote [GeneratedRegex(@"^ssh://([\w\-]+@)?[\w\.\-]+(\:[0-9]+)?/([a-zA-z0-9~%][\w\-\./~%]*)?[a-zA-Z0-9](\.git)?$")] private static partial Regex REG_SSH2(); - [GeneratedRegex(@"^git@([\w\.\-]+):([\w\.\-/~%]+/[\w\-\.%]+)\.git$")] - private static partial Regex REG_TO_VISIT_URL_CAPTURE(); + [GeneratedRegex(@"^[\w\-\.]+@([\w\.\-]+):/?([\w\.\-/~%]+/[\w\-\.%]+?)(\.git)?/?$")] + private static partial Regex REG_TO_VISIT_URL_CAPTURE_SCP(); + + [GeneratedRegex(@"^ssh://([\w\-\.]+@)?([\w\.\-]+)(\:[0-9]+)?/([\w\.\-/~%]+/[\w\-\.%]+?)(\.git)?/?$")] + private static partial Regex REG_TO_VISIT_URL_CAPTURE_SSH(); private static readonly Regex[] URL_FORMATS = [ REG_HTTPS(), @@ -78,13 +81,64 @@ public bool TryGetVisitURL(out string url) return true; } - var match = REG_TO_VISIT_URL_CAPTURE().Match(URL); + if (!TryParseSSH(URL, out var host, out var path)) + return false; + + // Azure DevOps uses a different path layout for SSH remotes. + // git@ssh.dev.azure.com:v3/{org}/{project}/{repo} => https://dev.azure.com/{org}/{project}/_git/{repo} + // {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo} => https://{org}.visualstudio.com/{project}/_git/{repo} + if (path.StartsWith("v3/", StringComparison.Ordinal)) + { + var parts = path.Split('/'); + if (parts.Length == 4) + { + if (host.Equals("ssh.dev.azure.com", StringComparison.Ordinal)) + { + url = $"https://dev.azure.com/{parts[1]}/{parts[2]}/_git/{parts[3]}"; + return true; + } + + if (host.Equals("vs-ssh.visualstudio.com", StringComparison.Ordinal)) + { + url = $"https://{parts[1]}.visualstudio.com/{parts[2]}/_git/{parts[3]}"; + return true; + } + } + } + + var supportHTTPS = HTTPSValidator.IsWellKnownHost(host) || HTTPSValidator.IsSupported(host); + var scheme = supportHTTPS ? "https" : "http"; + url = $"{scheme}://{host}/{path}"; + return true; + } + + public static bool TryGetSSHHost(string url, out string host) + { + return TryParseSSH(url, out host, out _); + } + + private static bool TryParseSSH(string url, out string host, out string path) + { + host = null; + path = null; + + if (string.IsNullOrEmpty(url)) + return false; + + // NOTE: The port of SSH remotes is not used, since the port of web page is unknown. + var match = REG_TO_VISIT_URL_CAPTURE_SSH().Match(url); + if (match.Success) + { + host = match.Groups[2].Value; + path = match.Groups[4].Value; + return true; + } + + match = REG_TO_VISIT_URL_CAPTURE_SCP().Match(url); if (match.Success) { - var host = match.Groups[1].Value; - var supportHTTPS = HTTPSValidator.IsSupported(host); - var scheme = supportHTTPS ? "https" : "http"; - url = $"{scheme}://{host}/{match.Groups[2].Value}"; + host = match.Groups[1].Value; + path = match.Groups[2].Value; return true; } From 86d3f014313cf4348d2ccd481a316333c025c535 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Wed, 14 Oct 2026 08:12:19 +0200 Subject: [PATCH 2/5] feature: add pull request platform detection and create-PR URL builder - `Models.PullRequestPlatform` detects GitHub, GitLab, Gitea, Forgejo/Codeberg, Bitbucket Cloud, Azure DevOps and Gitee, and builds the create-PR page URL with source, target, and (where supported) title/body, including cross-fork PRs - Per-remote platform override stored in `sourcegit.settings` for self-hosted servers that can not be detected by host name - `Commands.QueryRemoteHead` to resolve a remote's default branch Co-Authored-By: Claude Opus 5.5 --- src/Commands/QueryRemoteHead.cs | 36 ++++++ src/Models/PullRequestPlatform.cs | 201 ++++++++++++++++++++++++++++++ src/Models/RepositorySettings.cs | 6 + 3 files changed, 243 insertions(+) create mode 100644 src/Commands/QueryRemoteHead.cs create mode 100644 src/Models/PullRequestPlatform.cs diff --git a/src/Commands/QueryRemoteHead.cs b/src/Commands/QueryRemoteHead.cs new file mode 100644 index 0000000000..bdbeb0771c --- /dev/null +++ b/src/Commands/QueryRemoteHead.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryRemoteHead : Command + { + public QueryRemoteHead(string repo, string remote) + { + WorkingDirectory = repo; + Context = repo; + Args = $"symbolic-ref --short refs/remotes/{remote}/HEAD"; + + _remote = remote; + } + + /// + /// Returns the name of the remote's default branch (without the remote prefix), or null if unknown. + /// + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return null; + + var output = rs.StdOut.Trim(); + var prefix = $"{_remote}/"; + if (output.StartsWith(prefix, StringComparison.Ordinal)) + return output.Substring(prefix.Length); + + return null; + } + + private readonly string _remote; + } +} diff --git a/src/Models/PullRequestPlatform.cs b/src/Models/PullRequestPlatform.cs new file mode 100644 index 0000000000..94c1e936f5 --- /dev/null +++ b/src/Models/PullRequestPlatform.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json.Serialization; + +namespace SourceGit.Models +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum PullRequestPlatformType + { + None = 0, + GitHub, + GitLab, + Gitea, + Forgejo, + BitbucketCloud, + AzureDevOps, + Gitee, + } + + public class PullRequestPlatform + { + public const int MaxURLLength = 7000; + + public static readonly List Supported = [ + new(PullRequestPlatformType.GitHub, "GitHub", true, true, true), + new(PullRequestPlatformType.GitLab, "GitLab", true, true, true), + new(PullRequestPlatformType.Gitea, "Gitea", true, true, true), + new(PullRequestPlatformType.Forgejo, "Forgejo / Codeberg", false, false, true), + new(PullRequestPlatformType.BitbucketCloud, "Bitbucket Cloud", false, false, false), + new(PullRequestPlatformType.AzureDevOps, "Azure DevOps", false, false, false), + new(PullRequestPlatformType.Gitee, "Gitee", false, false, false), + ]; + + public PullRequestPlatformType Type { get; } + public string Name { get; } + public bool SupportsTitle { get; } + public bool SupportsBody { get; } + public bool SupportsCrossRepo { get; } + + public PullRequestPlatform(PullRequestPlatformType type, string name, bool supportsTitle, bool supportsBody, bool supportsCrossRepo) + { + Type = type; + Name = name; + SupportsTitle = supportsTitle; + SupportsBody = supportsBody; + SupportsCrossRepo = supportsCrossRepo; + } + + public static PullRequestPlatform Get(PullRequestPlatformType type) + { + return Supported.Find(x => x.Type == type); + } + + public static PullRequestPlatformType Detect(string host) + { + if (string.IsNullOrEmpty(host)) + return PullRequestPlatformType.None; + + host = host.ToLowerInvariant(); + + if (host.Contains("github", StringComparison.Ordinal)) + return PullRequestPlatformType.GitHub; + + if (host.Contains("gitlab", StringComparison.Ordinal)) + return PullRequestPlatformType.GitLab; + + if (host.Equals("codeberg.org", StringComparison.Ordinal) || host.Contains("forgejo", StringComparison.Ordinal)) + return PullRequestPlatformType.Forgejo; + + if (host.Contains("gitea", StringComparison.Ordinal)) + return PullRequestPlatformType.Gitea; + + if (host.Equals("bitbucket.org", StringComparison.Ordinal)) + return PullRequestPlatformType.BitbucketCloud; + + if (host.Equals("dev.azure.com", StringComparison.Ordinal) || host.EndsWith("visualstudio.com", StringComparison.Ordinal)) + return PullRequestPlatformType.AzureDevOps; + + if (host.Equals("gitee.com", StringComparison.Ordinal)) + return PullRequestPlatformType.Gitee; + + return PullRequestPlatformType.None; + } + + public static PullRequestPlatform Resolve(Remote remote, RepositorySettings settings) + { + if (remote == null) + return null; + + if (settings != null && + settings.PullRequestPlatforms.TryGetValue(remote.Name, out var overrided) && + overrided != PullRequestPlatformType.None) + return Get(overrided); + + if (!remote.TryGetVisitURL(out var url)) + return null; + + return Get(Detect(new Uri(url).Host)); + } + + /// + /// Builds the URL of the platform's "create pull request" page. + /// + /// Web URL of the repository that receives the pull request + /// Web URL of the repository that contains the source branch (same as target if not a fork) + /// True if the title or body could not be passed by URL + public string BuildCreateURL(string targetBaseURL, string sourceBaseURL, string sourceBranch, string targetBranch, string title, string body, out bool contentDropped) + { + contentDropped = (!string.IsNullOrEmpty(title) && !SupportsTitle) || (!string.IsNullOrEmpty(body) && !SupportsBody); + title = SupportsTitle ? title : null; + body = SupportsBody ? body : null; + + var url = BuildCreateURLWithContent(targetBaseURL, sourceBaseURL, sourceBranch, targetBranch, title, body); + if (url.Length > MaxURLLength && !string.IsNullOrEmpty(body)) + { + contentDropped = true; + url = BuildCreateURLWithContent(targetBaseURL, sourceBaseURL, sourceBranch, targetBranch, title, null); + } + + if (url.Length > MaxURLLength && !string.IsNullOrEmpty(title)) + url = BuildCreateURLWithContent(targetBaseURL, sourceBaseURL, sourceBranch, targetBranch, null, null); + + return url; + } + + private string BuildCreateURLWithContent(string targetBaseURL, string sourceBaseURL, string sourceBranch, string targetBranch, string title, string body) + { + targetBaseURL = targetBaseURL.TrimEnd('/'); + sourceBaseURL = string.IsNullOrEmpty(sourceBaseURL) ? targetBaseURL : sourceBaseURL.TrimEnd('/'); + + var isCrossRepo = !sourceBaseURL.Equals(targetBaseURL, StringComparison.OrdinalIgnoreCase); + var builder = new StringBuilder(1024); + + switch (Type) + { + case PullRequestPlatformType.GitHub: + case PullRequestPlatformType.Gitea: + case PullRequestPlatformType.Forgejo: + { + var head = EscapePath(sourceBranch); + if (isCrossRepo) + head = $"{Uri.EscapeDataString(GetOwner(sourceBaseURL))}:{head}"; + + builder.Append(targetBaseURL).Append("/compare/").Append(EscapePath(targetBranch)).Append("...").Append(head); + builder.Append(Type == PullRequestPlatformType.GitHub ? "?quick_pull=1" : "?expand=1"); + AppendQuery(builder, "title", title); + AppendQuery(builder, "body", body); + break; + } + case PullRequestPlatformType.GitLab: + // For forks, GitLab creates the merge request from the source project, and the target project defaults to the upstream one. + builder.Append(sourceBaseURL).Append("/-/merge_requests/new?"); + builder.Append("merge_request%5Bsource_branch%5D=").Append(Uri.EscapeDataString(sourceBranch)); + AppendQuery(builder, "merge_request%5Btarget_branch%5D", targetBranch); + AppendQuery(builder, "merge_request%5Btitle%5D", title); + AppendQuery(builder, "merge_request%5Bdescription%5D", body); + break; + case PullRequestPlatformType.BitbucketCloud: + builder.Append(targetBaseURL).Append("/pull-requests/new?source=").Append(Uri.EscapeDataString(sourceBranch)); + AppendQuery(builder, "dest", targetBranch); + break; + case PullRequestPlatformType.AzureDevOps: + builder.Append(targetBaseURL).Append("/pullrequestcreate?sourceRef=").Append(Uri.EscapeDataString(sourceBranch)); + AppendQuery(builder, "targetRef", targetBranch); + break; + case PullRequestPlatformType.Gitee: + builder.Append(targetBaseURL).Append("/pulls/new?source=").Append(Uri.EscapeDataString(sourceBranch)); + AppendQuery(builder, "target", targetBranch); + break; + default: + return targetBaseURL; + } + + return builder.ToString(); + } + + private static void AppendQuery(StringBuilder builder, string key, string value) + { + if (string.IsNullOrEmpty(value)) + return; + + builder.Append('&').Append(key).Append('=').Append(Uri.EscapeDataString(value)); + } + + private static string EscapePath(string branch) + { + var segments = branch.Split('/'); + for (int i = 0; i < segments.Length; i++) + segments[i] = Uri.EscapeDataString(segments[i]); + return string.Join('/', segments); + } + + private static string GetOwner(string baseURL) + { + var path = new Uri(baseURL).AbsolutePath.Trim('/'); + var idx = path.IndexOf('/'); + return idx > 0 ? path.Substring(0, idx) : path; + } + } +} diff --git a/src/Models/RepositorySettings.cs b/src/Models/RepositorySettings.cs index 506e6ded78..c171971e29 100644 --- a/src/Models/RepositorySettings.cs +++ b/src/Models/RepositorySettings.cs @@ -60,6 +60,12 @@ public AvaloniaList CustomActions set; } = []; + public Dictionary PullRequestPlatforms + { + get; + set; + } = []; + public static RepositorySettings Get(string gitCommonDir) { var fileInfo = new FileInfo(Path.Combine(gitCommonDir, "sourcegit.settings")); From f32f3c604db3703ce2ff942742e39b463e03a6b3 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Wed, 14 Oct 2026 08:17:48 +0200 Subject: [PATCH 3/5] feature: create pull request dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Right-click a remote branch while on another branch: `Create PR: → ...` - `Create PR for upstream ...` (local branch, or the upstream remote branch of current) opens the same dialog, targeting the remote's default branch - Dialog with searchable target branch, title and description prefilled from commits and the repository's PR/MR templates - Optionally push the source branch first (with `-u` and a remote choice when it has no upstream) - Opens the platform's prefilled create page; fields the platform can not prefill are copied to the clipboard - Asks for the hosting platform when it can not be detected from host name and remembers it per remote Co-Authored-By: Claude Opus 5.5 --- src/App.axaml.cs | 6 + src/Commands/QueryPullRequestTemplates.cs | 87 +++++ src/Commands/QueryRevisionFileNames.cs | 7 + src/Models/PullRequestTemplate.cs | 9 + src/Models/Remote.cs | 52 --- src/Resources/Locales/en_US.axaml | 15 +- src/ViewModels/CreatePullRequest.cs | 430 ++++++++++++++++++++++ src/Views/BranchTree.axaml.cs | 61 +-- src/Views/CreatePullRequest.axaml | 177 +++++++++ src/Views/CreatePullRequest.axaml.cs | 12 + 10 files changed, 780 insertions(+), 76 deletions(-) create mode 100644 src/Commands/QueryPullRequestTemplates.cs create mode 100644 src/Models/PullRequestTemplate.cs create mode 100644 src/ViewModels/CreatePullRequest.cs create mode 100644 src/Views/CreatePullRequest.axaml create mode 100644 src/Views/CreatePullRequest.axaml.cs diff --git a/src/App.axaml.cs b/src/App.axaml.cs index 66882b4869..5f4c7217e6 100644 --- a/src/App.axaml.cs +++ b/src/App.axaml.cs @@ -227,6 +227,12 @@ public static ViewModels.Launcher GetLauncher() return Current is App app ? app._launcher : null; } + public static async Task CopyTextAsync(string text) + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow.Clipboard: { } clipboard }) + await clipboard.SetTextAsync(text); + } + public static void Quit(int exitCode) { if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) diff --git a/src/Commands/QueryPullRequestTemplates.cs b/src/Commands/QueryPullRequestTemplates.cs new file mode 100644 index 0000000000..6e084b45dd --- /dev/null +++ b/src/Commands/QueryPullRequestTemplates.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + /// + /// Finds the pull/merge request templates (GitHub, GitLab, Gitea, Forgejo, Azure DevOps) in the given revision. + /// + public class QueryPullRequestTemplates + { + public QueryPullRequestTemplates(string repo, string revision) + { + _repo = repo; + _revision = revision; + } + + public async Task> GetResultAsync() + { + var templates = new List(); + + var lister = new QueryRevisionFileNames(_repo, _revision, LOOKUP_PATHS); + var files = await lister.GetResultAsync().ConfigureAwait(false); + foreach (var file in files) + { + if (!IsTemplate(file, out var isDefault)) + continue; + + using var stream = await QueryFileContent.RunAsync(_repo, _revision, file).ConfigureAwait(false); + using var reader = new StreamReader(stream); + var content = await reader.ReadToEndAsync().ConfigureAwait(false); + + var template = new Models.PullRequestTemplate() + { + Name = file, + Content = content.Trim(), + IsDefault = isDefault, + }; + + if (isDefault) + templates.Insert(0, template); + else + templates.Add(template); + } + + return templates; + } + + private static bool IsTemplate(string file, out bool isDefault) + { + isDefault = false; + + var path = file.ToLowerInvariant(); + if (!path.EndsWith(".md", StringComparison.Ordinal)) + return false; + + var name = Path.GetFileName(path); + var dir = Path.GetDirectoryName(path)?.Replace('\\', '/') ?? string.Empty; + + if (name.Equals("pull_request_template.md", StringComparison.Ordinal)) + { + isDefault = true; + return Array.IndexOf(SINGLE_TEMPLATE_DIRS, dir) >= 0; + } + + if (dir.Equals(".gitlab/merge_request_templates", StringComparison.Ordinal)) + { + isDefault = name.Equals("default.md", StringComparison.Ordinal); + return true; + } + + return Array.IndexOf(MULTIPLE_TEMPLATES_DIRS, dir) >= 0; + } + + private static readonly string[] LOOKUP_PATHS = [ + ".github", ".gitea", ".forgejo", ".gitlab/merge_request_templates", ".azuredevops", ".vsts", + "docs/pull_request_template.md", "docs/PULL_REQUEST_TEMPLATE.md", "pull_request_template.md", "PULL_REQUEST_TEMPLATE.md", + ]; + + private static readonly string[] SINGLE_TEMPLATE_DIRS = ["", ".github", ".gitea", ".forgejo", ".azuredevops", ".vsts", "docs"]; + private static readonly string[] MULTIPLE_TEMPLATES_DIRS = [".github/pull_request_template", ".gitea/pull_request_template", ".forgejo/pull_request_template"]; + + private readonly string _repo; + private readonly string _revision; + } +} diff --git a/src/Commands/QueryRevisionFileNames.cs b/src/Commands/QueryRevisionFileNames.cs index e4dcc25c51..bba42ee642 100644 --- a/src/Commands/QueryRevisionFileNames.cs +++ b/src/Commands/QueryRevisionFileNames.cs @@ -13,6 +13,13 @@ public QueryRevisionFileNames(string repo, string revision) Args = $"ls-tree -r --name-only {revision}"; } + public QueryRevisionFileNames(string repo, string revision, string[] paths) + { + WorkingDirectory = repo; + Context = repo; + Args = $"ls-tree -r --name-only {revision} -- {string.Join(' ', paths)}"; + } + public async Task> GetResultAsync() { var outs = new List(); diff --git a/src/Models/PullRequestTemplate.cs b/src/Models/PullRequestTemplate.cs new file mode 100644 index 0000000000..6973f2e84f --- /dev/null +++ b/src/Models/PullRequestTemplate.cs @@ -0,0 +1,9 @@ +namespace SourceGit.Models +{ + public class PullRequestTemplate + { + public string Name { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public bool IsDefault { get; set; } = false; + } +} diff --git a/src/Models/Remote.cs b/src/Models/Remote.cs index f080148a0f..51be0add94 100644 --- a/src/Models/Remote.cs +++ b/src/Models/Remote.cs @@ -1,7 +1,6 @@ using System; using System.IO; using System.Text.RegularExpressions; -using System.Web; namespace SourceGit.Models { @@ -144,56 +143,5 @@ private static bool TryParseSSH(string url, out string host, out string path) return false; } - - public bool TryGetCreatePullRequestURL(out string url, string mergeBranch) - { - url = null; - - if (!TryGetVisitURL(out var baseURL)) - return false; - - var uri = new Uri(baseURL); - var host = uri.Host; - var encodedBranch = HttpUtility.UrlEncode(mergeBranch); - - if (host.Contains("github.com", StringComparison.Ordinal)) - { - url = $"{baseURL}/compare/{encodedBranch}?expand=1"; - return true; - } - - if (host.Contains("gitlab", StringComparison.Ordinal)) - { - url = $"{baseURL}/-/merge_requests/new?merge_request%5Bsource_branch%5D={encodedBranch}"; - return true; - } - - if (host.Equals("gitee.com", StringComparison.Ordinal)) - { - url = $"{baseURL}/pulls/new?source={encodedBranch}"; - return true; - } - - if (host.Equals("bitbucket.org", StringComparison.Ordinal)) - { - url = $"{baseURL}/pull-requests/new?source={encodedBranch}"; - return true; - } - - if (host.Equals("gitea.org", StringComparison.Ordinal)) - { - url = $"{baseURL}/compare/{encodedBranch}"; - return true; - } - - if (host.Contains("azure.com", StringComparison.Ordinal) || - host.Contains("visualstudio.com", StringComparison.Ordinal)) - { - url = $"{baseURL}/pullrequestcreate?sourceRef={encodedBranch}"; - return true; - } - - return false; - } } } diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 76ac9e4dcb..f956cc0278 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -64,7 +64,7 @@ Compare with HEAD Compare with ${0}$ Copy Branch Name - Create PR... + Create PR: ${0}$ → ${1}$... Create PR for upstream ${0}$... Custom Action Delete ${0}$... @@ -325,6 +325,19 @@ Enter branch name. Create Local Branch Overwrite existing branch + Create Pull Request + This platform can not prefill the title and description. They will be copied to the clipboard so you can paste them on the opened page. + Title and description have been copied to the clipboard. Paste them on the opened page. + Description: + (None) + The source branch has not been pushed, or has local commits that are not pushed yet. + Platform: + Push the source branch before opening the page + Push To: + Source: + Target: + Template: + Title: Create Tag... New Tag At: GPG signing diff --git a/src/ViewModels/CreatePullRequest.cs b/src/ViewModels/CreatePullRequest.cs new file mode 100644 index 0000000000..9bb4c32013 --- /dev/null +++ b/src/ViewModels/CreatePullRequest.cs @@ -0,0 +1,430 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Text; +using System.Threading.Tasks; + +using Avalonia.Threading; + +namespace SourceGit.ViewModels +{ + public class CreatePullRequest : Popup + { + public Models.Branch Source + { + get; + } + + public bool NeedsPush + { + get; + } + + public bool PushFirst + { + get; + set; + } + + public bool IsPushRemoteSelectable + { + get => _upstream == null; + } + + public List PushRemotes + { + get; + } + + public Models.Remote SelectedPushRemote + { + get => _selectedPushRemote; + set + { + if (SetProperty(ref _selectedPushRemote, value)) + RefreshTargetBranches(); + } + } + + public List TargetBranches + { + get => _targetBranches; + private set => SetProperty(ref _targetBranches, value); + } + + [Required(ErrorMessage = "Target branch is required!!!")] + [CustomValidation(typeof(CreatePullRequest), nameof(ValidateTarget))] + public Models.Branch SelectedTarget + { + get => _selectedTarget; + set + { + if (SetProperty(ref _selectedTarget, value, true)) + PostTargetChanged(); + } + } + + public List Platforms + { + get => Models.PullRequestPlatform.Supported; + } + + public bool IsPlatformSelectable + { + get => _isPlatformSelectable; + private set => SetProperty(ref _isPlatformSelectable, value); + } + + [Required(ErrorMessage = "Platform is required!!!")] + public Models.PullRequestPlatform SelectedPlatform + { + get => _selectedPlatform; + set + { + if (SetProperty(ref _selectedPlatform, value, true)) + { + OnPropertyChanged(nameof(IsClipboardHintVisible)); + ValidateProperty(_selectedTarget, nameof(SelectedTarget)); + } + } + } + + public bool IsClipboardHintVisible + { + get => _selectedPlatform != null && (!_selectedPlatform.SupportsTitle || !_selectedPlatform.SupportsBody); + } + + public List Templates + { + get => _templates; + private set => SetProperty(ref _templates, value); + } + + public Models.PullRequestTemplate SelectedTemplate + { + get => _selectedTemplate; + set + { + if (SetProperty(ref _selectedTemplate, value)) + UpdateDefaultContent(); + } + } + + [Required(ErrorMessage = "Title is required!!!")] + public string Title + { + get => _title; + set + { + if (SetProperty(ref _title, value, true)) + _isTitleEdited = true; + } + } + + public string Description + { + get => _description; + set + { + // NOTE: TextBox may normalize line endings of generated content, which should not be treated as an edit. + if (SetProperty(ref _description, value) && !_generatedDescription.Equals(value?.ReplaceLineEndings("\n"), StringComparison.Ordinal)) + _isDescriptionEdited = true; + } + } + + public CreatePullRequest(Repository repo, Models.Branch source, Models.Branch target) + { + _repo = repo; + Source = source; + + if (!string.IsNullOrEmpty(source.Upstream) && !source.IsUpstreamGone) + _upstream = repo.Branches.Find(x => x.FullName.Equals(source.Upstream, StringComparison.Ordinal)); + + NeedsPush = _upstream == null || source.Ahead.Count > 0; + PushFirst = NeedsPush; + + PushRemotes = repo.Remotes; + _selectedPushRemote = _upstream != null ? repo.Remotes.Find(x => x.Name.Equals(_upstream.Remote, StringComparison.Ordinal)) : GetDefaultPushRemote(target); + + RefreshTargetBranches(); + + UpdateDefaultContent(); + + if (target != null) + SelectedTarget = target; + else + SelectDefaultTarget(); + } + + public static ValidationResult ValidateTarget(Models.Branch target, ValidationContext ctx) + { + if (ctx.ObjectInstance is CreatePullRequest { SelectedPlatform: { SupportsCrossRepo: false } } creator && + target != null && + !target.Remote.Equals(creator._selectedPushRemote?.Name, StringComparison.Ordinal)) + { + return new ValidationResult($"{creator.SelectedPlatform.Name} does not support pull requests between different repositories here!"); + } + + return ValidationResult.Success; + } + + public override async Task Sure() + { + var sourceRemote = _selectedPushRemote; + var targetRemote = _repo.Remotes.Find(x => x.Name.Equals(_selectedTarget.Remote, StringComparison.Ordinal)); + if (sourceRemote == null || targetRemote == null) + return false; + + if (PushFirst && NeedsPush) + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = $"Push {Source.Name} -> {sourceRemote.Name} ..."; + + var log = _repo.CreateLog("Push"); + Use(log); + + var remoteBranch = _upstream ?? new Models.Branch() { Name = Source.Name, Remote = sourceRemote.Name }; + var succ = await new Commands.Push( + _repo.FullPath, + Source, + sourceRemote, + remoteBranch, + false, + _repo.Submodules.Count > 0, + _upstream == null, + false, + false).Use(log).ExecAsync(); + + log.Complete(); + if (!succ) + return false; + } + + if (_isPlatformSelectable) + { + _repo.Settings.PullRequestPlatforms[targetRemote.Name] = _selectedPlatform.Type; + _repo.Settings.Save(); + } + + if (!targetRemote.TryGetVisitURL(out var targetURL) || !sourceRemote.TryGetVisitURL(out var sourceURL)) + return false; + + var sourceBranch = _upstream?.Name ?? Source.Name; + var url = _selectedPlatform.BuildCreateURL(targetURL, sourceURL, sourceBranch, _selectedTarget.Name, _title, _description, out var contentDropped); + if (contentDropped) + { + var content = string.IsNullOrEmpty(_description) ? _title : $"{_title}\n\n{_description}"; + await App.CopyTextAsync(content); + _repo.SendNotification(App.Text("CreatePR.CopiedToClipboard")); + } + + Native.OS.OpenBrowser(url); + return true; + } + + private Models.Remote GetDefaultPushRemote(Models.Branch target) + { + var remotes = _repo.Remotes; + if (remotes.Count == 0) + return null; + + if (!string.IsNullOrEmpty(_repo.Settings.DefaultRemote)) + { + var preferred = remotes.Find(x => x.Name.Equals(_repo.Settings.DefaultRemote, StringComparison.Ordinal)); + if (preferred != null) + return preferred; + } + + // Prefer a remote other than the target's (usually the fork of current user). + if (target != null) + { + var other = remotes.Find(x => !x.Name.Equals(target.Remote, StringComparison.Ordinal)); + if (other != null) + return other; + + return remotes.Find(x => x.Name.Equals(target.Remote, StringComparison.Ordinal)); + } + + return remotes[0]; + } + + private void RefreshTargetBranches() + { + var host = GetHost(_selectedPushRemote); + var branches = new List(); + foreach (var b in _repo.Branches) + { + if (b.IsLocal || b == _upstream) + continue; + + var remote = _repo.Remotes.Find(x => x.Name.Equals(b.Remote, StringComparison.Ordinal)); + if (host == null || host.Equals(GetHost(remote), StringComparison.OrdinalIgnoreCase)) + branches.Add(b); + } + + TargetBranches = branches; + + if (_selectedTarget == null) + return; + + if (!branches.Contains(_selectedTarget)) + SelectedTarget = null; + else + ValidateProperty(_selectedTarget, nameof(SelectedTarget)); + } + + private void SelectDefaultTarget() + { + var remote = _selectedPushRemote?.Name; + if (string.IsNullOrEmpty(remote)) + return; + + Task.Run(async () => + { + var head = await new Commands.QueryRemoteHead(_repo.FullPath, remote).GetResultAsync().ConfigureAwait(false); + Dispatcher.UIThread.Post(() => + { + if (_selectedTarget != null) + return; + + string[] candidates = string.IsNullOrEmpty(head) ? ["main", "master", "develop"] : [head]; + foreach (var name in candidates) + { + var found = _targetBranches.Find(x => x.Remote.Equals(remote, StringComparison.Ordinal) && x.Name.Equals(name, StringComparison.Ordinal)); + if (found != null) + { + SelectedTarget = found; + return; + } + } + }); + }); + } + + private void PostTargetChanged() + { + var targetRemote = _selectedTarget != null ? _repo.Remotes.Find(x => x.Name.Equals(_selectedTarget.Remote, StringComparison.Ordinal)) : null; + if (targetRemote != null) + { + IsPlatformSelectable = targetRemote.TryGetVisitURL(out var url) && + Models.PullRequestPlatform.Detect(new Uri(url).Host) == Models.PullRequestPlatformType.None; + SelectedPlatform = Models.PullRequestPlatform.Resolve(targetRemote, _repo.Settings); + } + + _commits = []; + _commitsVersion++; + + if (_selectedTarget == null) + { + Templates = []; + UpdateDefaultContent(); + return; + } + + var version = _commitsVersion; + var target = _selectedTarget.FullName; + var source = Source.FullName; + var repo = _repo.FullPath; + Task.Run(async () => + { + var commits = await new Commands.QueryCommits(repo, $"--no-merges -n 200 {target}..{source}", false).GetResultAsync().ConfigureAwait(false); + var singleCommitMessage = commits.Count == 1 ? await new Commands.QueryCommitFullMessage(repo, commits[0].SHA).GetResultAsync().ConfigureAwait(false) : null; + var templates = await new Commands.QueryPullRequestTemplates(repo, target).GetResultAsync().ConfigureAwait(false); + if (templates.Count > 0) + templates.Insert(0, new Models.PullRequestTemplate() { Name = App.Text("CreatePR.NoTemplate") }); + + Dispatcher.UIThread.Post(() => + { + if (version != _commitsVersion) + return; + + commits.Reverse(); + _commits = commits; + _singleCommitMessage = singleCommitMessage; + + var selected = _selectedTemplate != null ? templates.Find(x => x.Name.Equals(_selectedTemplate.Name, StringComparison.Ordinal)) : null; + selected ??= templates.Count > 1 ? templates[1] : null; + + Templates = templates; + _selectedTemplate = selected; + OnPropertyChanged(nameof(SelectedTemplate)); + UpdateDefaultContent(); + }); + }); + } + + private void UpdateDefaultContent() + { + if (!_isTitleEdited) + { + SetProperty(ref _title, _commits.Count == 1 ? _commits[0].Subject : HumanizeBranchName(Source.Name), nameof(Title)); + ValidateProperty(_title, nameof(Title)); + } + + if (_isDescriptionEdited) + return; + + var builder = new StringBuilder(); + if (!string.IsNullOrEmpty(_selectedTemplate?.Content)) + builder.Append(_selectedTemplate.Content).Append("\n\n"); + + if (_commits.Count == 1) + { + // Body of the only commit (without its subject). + var msg = _singleCommitMessage ?? string.Empty; + var idx = msg.IndexOf('\n'); + if (idx > 0) + builder.Append(msg.Substring(idx + 1).Trim()); + } + else if (_commits.Count > 1) + { + builder.Append("## Commits\n\n"); + foreach (var c in _commits) + builder.Append("- ").Append(c.Subject).Append('\n'); + } + + _generatedDescription = builder.ToString().Trim(); + SetProperty(ref _description, _generatedDescription, nameof(Description)); + } + + private static string HumanizeBranchName(string name) + { + var idx = name.LastIndexOf('/'); + if (idx >= 0) + name = name.Substring(idx + 1); + + name = name.Replace('-', ' ').Replace('_', ' ').Trim(); + if (name.Length == 0) + return name; + + return char.ToUpper(name[0], CultureInfo.CurrentCulture) + name.Substring(1); + } + + private static string GetHost(Models.Remote remote) + { + if (remote == null || !remote.TryGetVisitURL(out var url)) + return null; + + return new Uri(url).Host; + } + + private readonly Repository _repo = null; + private readonly Models.Branch _upstream = null; + private Models.Remote _selectedPushRemote = null; + private List _targetBranches = []; + private Models.Branch _selectedTarget = null; + private bool _isPlatformSelectable = false; + private Models.PullRequestPlatform _selectedPlatform = null; + private List _templates = []; + private Models.PullRequestTemplate _selectedTemplate = null; + private List _commits = []; + private string _singleCommitMessage = null; + private int _commitsVersion = 0; + private string _title = string.Empty; + private string _description = string.Empty; + private string _generatedDescription = string.Empty; + private bool _isTitleEdited = false; + private bool _isDescriptionEdited = false; + } +} diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs index 35d313e3a2..e575ceab48 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -1027,22 +1027,19 @@ private ContextMenu CreateContextMenuForLocalBranch(ViewModels.Repository repo, menu.Items.Add(createBranch); menu.Items.Add(createTag); - if (upstream != null) + if (upstream != null && GetRemoteHost(repo, upstream.Remote) != null) { - var remote = repo.Remotes.Find(x => x.Name.Equals(upstream.Remote, StringComparison.Ordinal)); - if (remote != null && remote.TryGetCreatePullRequestURL(out var prURL, upstream.Name)) + var createPR = new MenuItem(); + createPR.Header = App.Text("BranchCM.CreatePRForUpstream", upstream.FriendlyName); + createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); + createPR.Click += (_, e) => { - var createPR = new MenuItem(); - createPR.Header = App.Text("BranchCM.CreatePRForUpstream", upstream.FriendlyName); - createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); - createPR.Click += (_, e) => - { - Native.OS.OpenBrowser(prURL); - e.Handled = true; - }; + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreatePullRequest(repo, branch, null)); + e.Handled = true; + }; - menu.Items.Add(createPR); - } + menu.Items.Add(createPR); } menu.Items.Add(new MenuItem() { Header = "-" }); TryToAddCustomActionsToBranchContextMenu(repo, menu, branch); @@ -1352,19 +1349,28 @@ public ContextMenu CreateContextMenuForRemoteBranch(ViewModels.Repository repo, menu.Items.Add(createBranch); menu.Items.Add(createTag); - var remote = repo.Remotes.Find(x => x.Name.Equals(branch.Remote, StringComparison.Ordinal)); - if (remote != null && remote.TryGetCreatePullRequestURL(out var prURL, branch.Name)) + var head = repo.CurrentBranch; + var targetHost = GetRemoteHost(repo, branch.Remote); + if (head is { IsDetachedHead: false } && targetHost != null) { - var createPR = new MenuItem(); - createPR.Header = App.Text("BranchCM.CreatePR"); - createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); - createPR.Click += (_, e) => + // Clicking the upstream of current branch creates a PR for it, otherwise creates a PR from current branch into the clicked one. + var isUpstreamOfHead = branch.FullName.Equals(head.Upstream, StringComparison.Ordinal); + var headRemote = repo.Branches.Find(x => x.FullName.Equals(head.Upstream, StringComparison.Ordinal))?.Remote; + var headHost = string.IsNullOrEmpty(headRemote) ? null : GetRemoteHost(repo, headRemote); + if (isUpstreamOfHead || headHost == null || headHost.Equals(targetHost, StringComparison.OrdinalIgnoreCase)) { - Native.OS.OpenBrowser(prURL); - e.Handled = true; - }; + var createPR = new MenuItem(); + createPR.Header = isUpstreamOfHead ? App.Text("BranchCM.CreatePRForUpstream", name) : App.Text("BranchCM.CreatePR", head.Name, name); + createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); + createPR.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreatePullRequest(repo, head, isUpstreamOfHead ? null : branch)); + e.Handled = true; + }; - menu.Items.Add(createPR); + menu.Items.Add(createPR); + } } menu.Items.Add(new MenuItem() { Header = "-" }); @@ -1395,6 +1401,15 @@ public ContextMenu CreateContextMenuForRemoteBranch(ViewModels.Repository repo, return menu; } + private static string GetRemoteHost(ViewModels.Repository repo, string remoteName) + { + var remote = repo.Remotes.Find(x => x.Name.Equals(remoteName, StringComparison.Ordinal)); + if (remote == null || !remote.TryGetVisitURL(out var url)) + return null; + + return new Uri(url).Host; + } + private void TryToAddCustomActionsToBranchContextMenu(ViewModels.Repository repo, ContextMenu menu, Models.Branch branch) { var actions = repo.GetCustomActions(Models.CustomActionScope.Branch); diff --git a/src/Views/CreatePullRequest.axaml b/src/Views/CreatePullRequest.axaml new file mode 100644 index 0000000000..e7241aa36d --- /dev/null +++ b/src/Views/CreatePullRequest.axaml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CreatePullRequest.axaml.cs b/src/Views/CreatePullRequest.axaml.cs new file mode 100644 index 0000000000..13ec296151 --- /dev/null +++ b/src/Views/CreatePullRequest.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class CreatePullRequest : UserControl + { + public CreatePullRequest() + { + InitializeComponent(); + } + } +} From f4402d2066f1baeec53951b7165e7ea286600e71 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Thu, 15 Oct 2026 08:15:05 +0200 Subject: [PATCH 4/5] feature: create PR from the branch submenus of the commit context menu in history graph Co-Authored-By: Claude Opus 5.5 --- src/ViewModels/CreatePullRequest.cs | 39 +++++++++++++++++++++++++++++ src/Views/BranchTree.axaml.cs | 38 ++++++++-------------------- src/Views/Histories.axaml.cs | 31 +++++++++++++++++++++++ 3 files changed, 81 insertions(+), 27 deletions(-) diff --git a/src/ViewModels/CreatePullRequest.cs b/src/ViewModels/CreatePullRequest.cs index 9bb4c32013..1e4ba1e3df 100644 --- a/src/ViewModels/CreatePullRequest.cs +++ b/src/ViewModels/CreatePullRequest.cs @@ -157,6 +157,40 @@ public CreatePullRequest(Repository repo, Models.Branch source, Models.Branch ta SelectDefaultTarget(); } + /// + /// Checks if a pull request can be created for the given local branch's upstream. + /// + public static bool CanCreateForUpstream(Repository repo, Models.Branch upstream) + { + return upstream != null && GetRemoteHost(repo, upstream.Remote) != null; + } + + /// + /// Checks if a pull request can be created from the current branch into the given remote branch. + /// is true when the remote branch is the upstream of current branch, + /// in which case the pull request is created for the current branch with the remote's default branch as target. + /// + public static bool CanCreateInto(Repository repo, Models.Branch head, Models.Branch target, out bool isUpstreamOfHead) + { + isUpstreamOfHead = false; + + if (head is not { IsDetachedHead: false } || target == null || target.IsLocal) + return false; + + var targetHost = GetRemoteHost(repo, target.Remote); + if (targetHost == null) + return false; + + isUpstreamOfHead = target.FullName.Equals(head.Upstream, StringComparison.Ordinal); + if (isUpstreamOfHead) + return true; + + // Only allows pull requests between repositories on the same server. + var headRemote = repo.Branches.Find(x => x.FullName.Equals(head.Upstream, StringComparison.Ordinal))?.Remote; + var headHost = string.IsNullOrEmpty(headRemote) ? null : GetRemoteHost(repo, headRemote); + return headHost == null || headHost.Equals(targetHost, StringComparison.OrdinalIgnoreCase); + } + public static ValidationResult ValidateTarget(Models.Branch target, ValidationContext ctx) { if (ctx.ObjectInstance is CreatePullRequest { SelectedPlatform: { SupportsCrossRepo: false } } creator && @@ -401,6 +435,11 @@ private static string HumanizeBranchName(string name) return char.ToUpper(name[0], CultureInfo.CurrentCulture) + name.Substring(1); } + private static string GetRemoteHost(Repository repo, string remoteName) + { + return GetHost(repo.Remotes.Find(x => x.Name.Equals(remoteName, StringComparison.Ordinal))); + } + private static string GetHost(Models.Remote remote) { if (remote == null || !remote.TryGetVisitURL(out var url)) diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs index e575ceab48..6451c313ce 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -1027,7 +1027,7 @@ private ContextMenu CreateContextMenuForLocalBranch(ViewModels.Repository repo, menu.Items.Add(createBranch); menu.Items.Add(createTag); - if (upstream != null && GetRemoteHost(repo, upstream.Remote) != null) + if (ViewModels.CreatePullRequest.CanCreateForUpstream(repo, upstream)) { var createPR = new MenuItem(); createPR.Header = App.Text("BranchCM.CreatePRForUpstream", upstream.FriendlyName); @@ -1350,27 +1350,20 @@ public ContextMenu CreateContextMenuForRemoteBranch(ViewModels.Repository repo, menu.Items.Add(createTag); var head = repo.CurrentBranch; - var targetHost = GetRemoteHost(repo, branch.Remote); - if (head is { IsDetachedHead: false } && targetHost != null) + if (ViewModels.CreatePullRequest.CanCreateInto(repo, head, branch, out var isUpstreamOfHead)) { // Clicking the upstream of current branch creates a PR for it, otherwise creates a PR from current branch into the clicked one. - var isUpstreamOfHead = branch.FullName.Equals(head.Upstream, StringComparison.Ordinal); - var headRemote = repo.Branches.Find(x => x.FullName.Equals(head.Upstream, StringComparison.Ordinal))?.Remote; - var headHost = string.IsNullOrEmpty(headRemote) ? null : GetRemoteHost(repo, headRemote); - if (isUpstreamOfHead || headHost == null || headHost.Equals(targetHost, StringComparison.OrdinalIgnoreCase)) + var createPR = new MenuItem(); + createPR.Header = isUpstreamOfHead ? App.Text("BranchCM.CreatePRForUpstream", name) : App.Text("BranchCM.CreatePR", head.Name, name); + createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); + createPR.Click += (_, e) => { - var createPR = new MenuItem(); - createPR.Header = isUpstreamOfHead ? App.Text("BranchCM.CreatePRForUpstream", name) : App.Text("BranchCM.CreatePR", head.Name, name); - createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); - createPR.Click += (_, e) => - { - if (repo.CanCreatePopup()) - repo.ShowPopup(new ViewModels.CreatePullRequest(repo, head, isUpstreamOfHead ? null : branch)); - e.Handled = true; - }; + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreatePullRequest(repo, head, isUpstreamOfHead ? null : branch)); + e.Handled = true; + }; - menu.Items.Add(createPR); - } + menu.Items.Add(createPR); } menu.Items.Add(new MenuItem() { Header = "-" }); @@ -1401,15 +1394,6 @@ public ContextMenu CreateContextMenuForRemoteBranch(ViewModels.Repository repo, return menu; } - private static string GetRemoteHost(ViewModels.Repository repo, string remoteName) - { - var remote = repo.Remotes.Find(x => x.Name.Equals(remoteName, StringComparison.Ordinal)); - if (remote == null || !remote.TryGetVisitURL(out var url)) - return null; - - return new Uri(url).Host; - } - private void TryToAddCustomActionsToBranchContextMenu(ViewModels.Repository repo, ContextMenu menu, Models.Branch branch) { var actions = repo.GetCustomActions(Models.CustomActionScope.Branch); diff --git a/src/Views/Histories.axaml.cs b/src/Views/Histories.axaml.cs index 846c59d1b1..baf150dcd3 100644 --- a/src/Views/Histories.axaml.cs +++ b/src/Views/Histories.axaml.cs @@ -1608,6 +1608,22 @@ private void FillCurrentBranchMenu(ContextMenu menu, ViewModels.Repository repo, submenu.Items.Add(rename); submenu.Items.Add(new MenuItem() { Header = "-" }); + var upstreamBranch = string.IsNullOrEmpty(current.Upstream) || current.IsUpstreamGone ? null : repo.Branches.Find(x => x.FullName.Equals(current.Upstream, StringComparison.Ordinal)); + if (ViewModels.CreatePullRequest.CanCreateForUpstream(repo, upstreamBranch)) + { + var createPR = new MenuItem(); + createPR.Header = App.Text("BranchCM.CreatePRForUpstream", upstreamBranch.FriendlyName); + createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); + createPR.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreatePullRequest(repo, current, null)); + e.Handled = true; + }; + submenu.Items.Add(createPR); + submenu.Items.Add(new MenuItem() { Header = "-" }); + } + if (!repo.IsBare) { var type = repo.GetGitFlowType(current); @@ -1872,6 +1888,21 @@ private void FillRemoteBranchMenu(ContextMenu menu, ViewModels.Repository repo, submenu.Items.Add(compare); submenu.Items.Add(new MenuItem() { Header = "-" }); + if (ViewModels.CreatePullRequest.CanCreateInto(repo, current, branch, out var isUpstreamOfHead)) + { + var createPR = new MenuItem(); + createPR.Header = isUpstreamOfHead ? App.Text("BranchCM.CreatePRForUpstream", name) : App.Text("BranchCM.CreatePR", current.Name, name); + createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); + createPR.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreatePullRequest(repo, current, isUpstreamOfHead ? null : branch)); + e.Handled = true; + }; + submenu.Items.Add(createPR); + submenu.Items.Add(new MenuItem() { Header = "-" }); + } + var copy = new MenuItem(); copy.Header = App.Text("BranchCM.CopyName"); copy.Icon = this.CreateMenuIcon("Icons.Copy"); From 64883886364f6f4ff6ddace777cf1e07e12e3748 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Thu, 15 Oct 2026 08:21:46 +0200 Subject: [PATCH 5/5] enhance: distinguish unpushed branch from unpushed commits in create PR dialog Co-Authored-By: Claude Opus 5.5 --- src/Resources/Locales/en_US.axaml | 6 ++++-- src/Views/CreatePullRequest.axaml | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index f956cc0278..2b75f5d25c 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -326,13 +326,15 @@ Create Local Branch Overwrite existing branch Create Pull Request + The source branch has not been pushed yet. This platform can not prefill the title and description. They will be copied to the clipboard so you can paste them on the opened page. + The source branch has commits that have not been pushed yet. Title and description have been copied to the clipboard. Paste them on the opened page. Description: (None) - The source branch has not been pushed, or has local commits that are not pushed yet. Platform: - Push the source branch before opening the page + Push the source branch before opening the PR + Push the local commits before opening the PR Push To: Source: Target: diff --git a/src/Views/CreatePullRequest.axaml b/src/Views/CreatePullRequest.axaml index e7241aa36d..4b62778471 100644 --- a/src/Views/CreatePullRequest.axaml +++ b/src/Views/CreatePullRequest.axaml @@ -142,9 +142,13 @@ + IsVisible="{Binding NeedsPush}"> + + + + + @@ -155,9 +159,15 @@ Data="{StaticResource Icons.Error}" Fill="DarkOrange"/> + Foreground="DarkOrange" + IsVisible="{Binding IsPushRemoteSelectable}"/> +