diff --git a/README.md b/README.md
index 2ac5ba0723..d38b46603d 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,11 @@
+> [!NOTE]
+> This is a fork of [SourceGit](https://github.com/sourcegit-scm/sourcegit) with two extra features:
+>
+> - **`Avatars` commit graph style**: branches/tags, graph and commit message in separate resizable columns, with author avatars as graph nodes ([sourcegit-scm/sourcegit#2779](https://github.com/sourcegit-scm/sourcegit/pull/2779))
+> - **Defaults for Fetch, Pull and Push**: one-click actions with saved default options, dropdowns for the option popups, and a spinner/checkmark under the buttons ([sourcegit-scm/sourcegit#2777](https://github.com/sourcegit-scm/sourcegit/pull/2777))
+>
+> 
+
# SourceGit - Opensource Git GUI client.
[](https://github.com/sourcegit-scm/sourcegit/stargazers)
diff --git a/screenshots/commit_graph_avatars.png b/screenshots/commit_graph_avatars.png
new file mode 100644
index 0000000000..112056a077
Binary files /dev/null and b/screenshots/commit_graph_avatars.png differ
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/Converters/CommitGraphStyleConverters.cs b/src/Converters/CommitGraphStyleConverters.cs
new file mode 100644
index 0000000000..ffa8505b13
--- /dev/null
+++ b/src/Converters/CommitGraphStyleConverters.cs
@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+
+using Avalonia;
+using Avalonia.Data.Converters;
+
+namespace SourceGit.Converters
+{
+ public static class CommitGraphStyleConverters
+ {
+ public static readonly FuncValueConverter IsClassic =
+ new(v => v == Models.CommitGraphStyle.Classic);
+
+ public static readonly FuncValueConverter IsAvatars =
+ new(v => v == Models.CommitGraphStyle.Avatars);
+
+ public static readonly FuncValueConverter ToRowHeight =
+ new(v => v == Models.CommitGraphStyle.Avatars ? 28 : 26);
+
+ // Places a hit-area centered on the commit's avatar in the graph column.
+ public static readonly FuncValueConverter ToAvatarHitAreaMargin =
+ new(v => new Thickness(Views.CommitGraph.GetAvatarsStyleX(v) - 12, 0, 0, 0));
+
+ // Values: [Commit.LeftMargin, CommitGraphStyle]. Only the classic style draws the graph under the subject.
+ public static readonly FuncMultiValueConverter