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/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/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/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/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/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/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 ab16874d26..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
{
@@ -19,8 +18,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,64 +80,64 @@ public bool TryGetVisitURL(out string url)
return true;
}
- var match = REG_TO_VISIT_URL_CAPTURE().Match(URL);
- if (match.Success)
+ 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 host = match.Groups[1].Value;
- var supportHTTPS = HTTPSValidator.IsSupported(host);
- var scheme = supportHTTPS ? "https" : "http";
- url = $"{scheme}://{host}/{match.Groups[2].Value}";
- return true;
+ 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;
+ }
+ }
}
- return false;
+ var supportHTTPS = HTTPSValidator.IsWellKnownHost(host) || HTTPSValidator.IsSupported(host);
+ var scheme = supportHTTPS ? "https" : "http";
+ url = $"{scheme}://{host}/{path}";
+ return true;
}
- public bool TryGetCreatePullRequestURL(out string url, string mergeBranch)
+ public static bool TryGetSSHHost(string url, out string host)
{
- 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;
- }
+ return TryParseSSH(url, out host, out _);
+ }
- if (host.Equals("gitee.com", StringComparison.Ordinal))
- {
- url = $"{baseURL}/pulls/new?source={encodedBranch}";
- return true;
- }
+ private static bool TryParseSSH(string url, out string host, out string path)
+ {
+ host = null;
+ path = null;
- if (host.Equals("bitbucket.org", StringComparison.Ordinal))
- {
- url = $"{baseURL}/pull-requests/new?source={encodedBranch}";
- return true;
- }
+ if (string.IsNullOrEmpty(url))
+ return false;
- if (host.Equals("gitea.org", StringComparison.Ordinal))
+ // 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)
{
- url = $"{baseURL}/compare/{encodedBranch}";
+ host = match.Groups[2].Value;
+ path = match.Groups[4].Value;
return true;
}
- if (host.Contains("azure.com", StringComparison.Ordinal) ||
- host.Contains("visualstudio.com", StringComparison.Ordinal))
+ match = REG_TO_VISIT_URL_CAPTURE_SCP().Match(url);
+ if (match.Success)
{
- url = $"{baseURL}/pullrequestcreate?sourceRef={encodedBranch}";
+ host = match.Groups[1].Value;
+ path = match.Groups[2].Value;
return true;
}
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"));
diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml
index d3ffb203a4..710c3b3800 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,21 @@
Enter branch name.
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)
+ Platform:
+ Push the source branch before opening the PR
+ Push the local commits before opening the PR
+ 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..1e4ba1e3df
--- /dev/null
+++ b/src/ViewModels/CreatePullRequest.cs
@@ -0,0 +1,469 @@
+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();
+ }
+
+ ///
+ /// 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 &&
+ 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 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))
+ 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..6451c313ce 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 (ViewModels.CreatePullRequest.CanCreateForUpstream(repo, upstream))
{
- 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,15 +1349,17 @@ 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;
+ 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 createPR = new MenuItem();
- createPR.Header = App.Text("BranchCM.CreatePR");
+ createPR.Header = isUpstreamOfHead ? App.Text("BranchCM.CreatePRForUpstream", name) : App.Text("BranchCM.CreatePR", head.Name, name);
createPR.Icon = this.CreateMenuIcon("Icons.CreatePR");
createPR.Click += (_, e) =>
{
- Native.OS.OpenBrowser(prURL);
+ if (repo.CanCreatePopup())
+ repo.ShowPopup(new ViewModels.CreatePullRequest(repo, head, isUpstreamOfHead ? null : branch));
e.Handled = true;
};
diff --git a/src/Views/CreatePullRequest.axaml b/src/Views/CreatePullRequest.axaml
new file mode 100644
index 0000000000..4b62778471
--- /dev/null
+++ b/src/Views/CreatePullRequest.axaml
@@ -0,0 +1,187 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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();
+ }
+ }
+}
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");