From b446ad3d081845b42801197aa5b739c38ada102b Mon Sep 17 00:00:00 2001 From: Ivn Nv Date: Tue, 5 May 2026 16:44:58 +0300 Subject: [PATCH] feature: add Compare with default base on current branch menu Other local branches in the sidebar already have a one-click Compare with HEAD entry, but the current branch's right-click only offers the free-form Compare with... palette. Adds a Compare with entry that opens the existing Compare window pre-filled with the repo's default base branch (resolved via refs/remotes//HEAD). The remote selection follows the same policy used elsewhere (current branch's upstream remote -> Settings.DefaultRemote -> first remote). The entry is hidden when the remote HEAD is unset or when the resolved base equals the current branch. Adds Commands/QueryDefaultBranch (one symbolic-ref call) and a small resolver in BranchTree.axaml.cs. No changes to Compare, CompareRevisions, or DiffOption. --- src/Commands/QueryDefaultBranch.cs | 27 ++++++++++++ src/Resources/Locales/en_US.axaml | 1 + src/Views/BranchTree.axaml.cs | 67 +++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 src/Commands/QueryDefaultBranch.cs diff --git a/src/Commands/QueryDefaultBranch.cs b/src/Commands/QueryDefaultBranch.cs new file mode 100644 index 000000000..5f40ab3c6 --- /dev/null +++ b/src/Commands/QueryDefaultBranch.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryDefaultBranch : Command + { + public QueryDefaultBranch(string repo, string remote) + { + WorkingDirectory = repo; + Context = repo; + Args = $"symbolic-ref --short refs/remotes/{remote}/HEAD"; + RaiseError = false; + } + + public string GetResult() + { + var rs = ReadToEnd(); + return rs.IsSuccess ? rs.StdOut.Trim() : string.Empty; + } + + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + return rs.IsSuccess ? rs.StdOut.Trim() : string.Empty; + } + } +} diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 5d5b4a90b..716d1348b 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -63,6 +63,7 @@ Checkout ${0}$... Compare selected 2 branches Compare with... + Compare with ${0}$ Compare with HEAD Copy Branch Name Create PR... diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs index 02f40085c..6bf735c9a 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -733,6 +733,21 @@ private ContextMenu CreateContextMenuForLocalBranch(ViewModels.Repository repo, menu.Items.Add(push); + menu.Items.Add(new MenuItem() { Header = "-" }); + + var defaultBase = ResolveDefaultBaseBranch(repo); + if (defaultBase != null && !defaultBase.FullName.Equals(branch.FullName, StringComparison.Ordinal)) + { + var compareWithDefault = new MenuItem(); + compareWithDefault.Header = App.Text("BranchCM.CompareWithDefault", defaultBase.FriendlyName); + compareWithDefault.Icon = this.CreateMenuIcon("Icons.Compare"); + compareWithDefault.Click += (_, _) => + { + this.ShowWindow(new ViewModels.Compare(repo, defaultBase, branch)); + }; + menu.Items.Add(compareWithDefault); + } + var compareWith = new MenuItem(); compareWith.Header = App.Text("BranchCM.CompareWith"); compareWith.Icon = this.CreateMenuIcon("Icons.Compare"); @@ -740,7 +755,6 @@ private ContextMenu CreateContextMenuForLocalBranch(ViewModels.Repository repo, { new ViewModels.CompareCommandPalette(repo, branch).Open(); }; - menu.Items.Add(new MenuItem() { Header = "-" }); menu.Items.Add(compareWith); } else @@ -1352,6 +1366,57 @@ private void TryToAddCustomActionsToRemoteContextMenu(ViewModels.Repository repo menu.Items.Add(new MenuItem() { Header = "-" }); } + // Resolves the repository's "default" branch (the equivalent of GitHub's default branch). + // Remote priority: Settings.DefaultRemote -> current branch's upstream remote -> first remote. + // Prefers the remote-tracking ref (e.g. origin/master) over a same-named local branch so + // the comparison is against the actual remote default. Returns null if the remote HEAD is + // unset or no remote exists. + private Models.Branch ResolveDefaultBaseBranch(ViewModels.Repository repo) + { + if (repo.Remotes.Count == 0) + return null; + + // Prefer an explicitly-configured default remote ("upstream" in fork workflows), + // then fall back to the current branch's upstream remote, then the first remote. + string remoteName = null; + if (!string.IsNullOrEmpty(repo.Settings?.DefaultRemote)) + remoteName = repo.Settings.DefaultRemote; + + if (string.IsNullOrEmpty(remoteName)) + { + var current = repo.CurrentBranch; + const int prefixLen = 13; // "refs/remotes/" + if (current != null && !string.IsNullOrEmpty(current.Upstream) && current.Upstream.Length > prefixLen) + { + var sepIdx = current.Upstream.IndexOf('/', prefixLen); + if (sepIdx > prefixLen) + remoteName = current.Upstream.Substring(prefixLen, sepIdx - prefixLen); + } + } + + if (string.IsNullOrEmpty(remoteName)) + remoteName = repo.Remotes[0].Name; + + var fullRef = new Commands.QueryDefaultBranch(repo.FullPath, remoteName).GetResult(); + if (string.IsNullOrEmpty(fullRef)) + return null; + + // QueryDefaultBranch returns e.g. "origin/master". Prefer the remote-tracking ref + // (authoritative for "what is the default base on the remote?"), fall back to a + // local branch with the same short name only if the remote-tracking ref is absent. + var remoteTrackingRef = $"refs/remotes/{fullRef}"; + var remoteMatch = repo.Branches.Find(b => !b.IsLocal && b.FullName.Equals(remoteTrackingRef, System.StringComparison.Ordinal)); + if (remoteMatch != null) + return remoteMatch; + + var slash = fullRef.IndexOf('/'); + if (slash <= 0 || slash >= fullRef.Length - 1) + return null; + + var shortName = fullRef.Substring(slash + 1); + return repo.Branches.Find(b => b.IsLocal && b.Name.Equals(shortName, System.StringComparison.Ordinal)); + } + private bool _disableSelectionChangingEvent = false; } }