From 6d59768afef9fd3c7d4a93f50bcfaaaf4dc4399c Mon Sep 17 00:00:00 2001 From: Martin Dindoffer Date: Fri, 17 Jul 2026 09:59:43 +0200 Subject: [PATCH 1/2] feature: add fulltext commit search across SHA/author/committer/message (#985) Git cannot OR --grep/--author/--committer in a single invocation, so the new `All` method (now the default) runs the field queries separately, plus an exact-SHA lookup, and merges the deduplicated results sorted by commit time. Adds an internal-only `ByCommitter` query mode to power it. Co-Authored-By: Claude Fable 5 --- src/Commands/QueryCommits.cs | 4 ++ src/Converters/IntConverters.cs | 3 ++ src/Models/Commit.cs | 7 ++- src/Resources/Locales/en_US.axaml | 1 + src/ViewModels/SearchCommitContext.cs | 76 ++++++++++++++++++++++----- src/Views/CommitSearchView.axaml | 3 +- 6 files changed, 78 insertions(+), 16 deletions(-) diff --git a/src/Commands/QueryCommits.cs b/src/Commands/QueryCommits.cs index 1a5073988d..64a713764e 100644 --- a/src/Commands/QueryCommits.cs +++ b/src/Commands/QueryCommits.cs @@ -28,6 +28,10 @@ public QueryCommits(string repo, string filter, Models.CommitSearchMethod method { builder.Append("-i --author=").Append(filter.Quoted()); } + else if (method == Models.CommitSearchMethod.ByCommitter) + { + builder.Append("-i --committer=").Append(filter.Quoted()); + } else if (method == Models.CommitSearchMethod.ByMessage) { var words = filter.Split([' ', '\t', '\r'], StringSplitOptions.RemoveEmptyEntries); diff --git a/src/Converters/IntConverters.cs b/src/Converters/IntConverters.cs index 4d6c723a8d..628132734b 100644 --- a/src/Converters/IntConverters.cs +++ b/src/Converters/IntConverters.cs @@ -14,6 +14,9 @@ public static class IntConverters public static readonly FuncValueConverter IsZero = new(v => v == 0); + public static readonly FuncValueConverter IsNotOne = + new(v => v != 1); + public static readonly FuncValueConverter ToTreeMargin = new(v => new Thickness(v * 16, 0, 0, 0)); diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index de299ed6b7..2c76100028 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -4,13 +4,18 @@ namespace SourceGit.Models { + // Bound by index to the search-method ComboBox in Views/Repository.axaml, so + // UI-selectable methods must stay first and in dropdown order. ByCommitter is + // internal-only (used by the fulltext `All` search). public enum CommitSearchMethod { - BySHA = 0, + All = 0, + BySHA, ByAuthor, ByMessage, ByPath, ByContent, + ByCommitter, } public class Commit : ObservableObject diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 9abb3bdcc9..5f9a5689bd 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -839,6 +839,7 @@ Add Remote RESOLVE SEARCH COMMITS + SHA/Author/Message Author Content Message diff --git a/src/ViewModels/SearchCommitContext.cs b/src/ViewModels/SearchCommitContext.cs index f7885e809e..f81aca6317 100644 --- a/src/ViewModels/SearchCommitContext.cs +++ b/src/ViewModels/SearchCommitContext.cs @@ -129,22 +129,21 @@ public void StartSearch() result.Add(commit); } } - else if (_onlySearchCurrentBranch) - { - result = await new Commands.QueryCommits(_repo, _filter, method, true) - .GetResultAsync() - .ConfigureAwait(false); - - foreach (var c in result) - c.IsMerged = true; - } else { - result = await new Commands.QueryCommits(_repo, _filter, method, false) - .GetResultAsync() - .ConfigureAwait(false); + if (method == Models.CommitSearchMethod.All) + result = await SearchAllFieldsAsync().ConfigureAwait(false); + else + result = await new Commands.QueryCommits(_repo, _filter, method, _onlySearchCurrentBranch) + .GetResultAsync() + .ConfigureAwait(false); - if (result.Count > 0) + if (_onlySearchCurrentBranch) + { + foreach (var c in result) + c.IsMerged = true; + } + else if (result.Count > 0) { var set = await new Commands.QueryCurrentBranchCommitHashes(_repo, result[^1].CommitterTime) .GetResultAsync() @@ -171,6 +170,55 @@ public void StartSearch() }, token); } + private async Task> SearchAllFieldsAsync() + { + var result = new List(); + var seen = new HashSet(); + + var isCommitSHA = await new Commands.IsCommitSHA(_repo, _filter) + .GetResultAsync() + .ConfigureAwait(false); + + if (isCommitSHA) + { + var commit = await new Commands.QuerySingleCommit(_repo, _filter) + .GetResultAsync() + .ConfigureAwait(false); + + var matchesScope = commit != null; + if (matchesScope && _onlySearchCurrentBranch) + matchesScope = await new Commands.IsAncestor(_repo, commit.SHA, "HEAD") + .GetResultAsync() + .ConfigureAwait(false); + + if (matchesScope && seen.Add(commit.SHA)) + result.Add(commit); + } + + var fields = new[] + { + Models.CommitSearchMethod.ByMessage, + Models.CommitSearchMethod.ByAuthor, + Models.CommitSearchMethod.ByCommitter, + }; + + foreach (var field in fields) + { + var matched = await new Commands.QueryCommits(_repo, _filter, field, _onlySearchCurrentBranch) + .GetResultAsync() + .ConfigureAwait(false); + + foreach (var c in matched) + { + if (seen.Add(c.SHA)) + result.Add(c); + } + } + + result.Sort((l, r) => r.CommitterTime.CompareTo(l.CommitterTime)); + return result; + } + public void EndSearch() { if (_cancellation is { IsCancellationRequested: false }) @@ -288,7 +336,7 @@ private void UpdateSuggestions() private string _repo = null; private Histories _histories = null; private CancellationTokenSource _cancellation = null; - private int _method = (int)Models.CommitSearchMethod.ByMessage; + private int _method = (int)Models.CommitSearchMethod.All; private string _filter = string.Empty; private bool _onlySearchCurrentBranch = false; private bool _isQuerying = false; diff --git a/src/Views/CommitSearchView.axaml b/src/Views/CommitSearchView.axaml index 85141f20c7..7137e66cfe 100644 --- a/src/Views/CommitSearchView.axaml +++ b/src/Views/CommitSearchView.axaml @@ -145,6 +145,7 @@ BorderBrush="{DynamicResource Brush.Border2}" SelectedIndex="{Binding Method, Mode=TwoWay}"> + @@ -156,7 +157,7 @@ + IsVisible="{Binding Method, Converter={x:Static c:IntConverters.IsNotOne}}"> From 16b7e072f70028401d471b9246e825e1b6f20074 Mon Sep 17 00:00:00 2001 From: Martin Dindoffer Date: Mon, 26 Oct 2026 21:43:46 +0100 Subject: [PATCH 2/2] perf: run fulltext commit search queries concurrently Git ANDs --grep/--author/--committer within a single invocation, so the `All` search needs one `git log` per field. Each of those is a full history walk on large repositories, and running them one after another multiplied the wall time by the number of fields (10+ seconds on the Unreal Engine repo versus under 5 for a single-field search). Run the message query, the author query and the exact-SHA lookup concurrently with Task.WhenAll so wall time stays close to a single-field search. Co-Authored-By: Claude Fable 5.1 --- src/Models/Commit.cs | 6 +-- src/ViewModels/SearchCommitContext.cs | 71 +++++++++++++++------------ 2 files changed, 43 insertions(+), 34 deletions(-) diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index 2c76100028..efd9c22917 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -4,9 +4,9 @@ namespace SourceGit.Models { - // Bound by index to the search-method ComboBox in Views/Repository.axaml, so - // UI-selectable methods must stay first and in dropdown order. ByCommitter is - // internal-only (used by the fulltext `All` search). + // Bound by index to the search-method ComboBox in Views/CommitSearchView.axaml, + // so UI-selectable methods must stay first and in dropdown order. ByCommitter + // is internal-only (used by the fulltext `All` search). public enum CommitSearchMethod { All = 0, diff --git a/src/ViewModels/SearchCommitContext.cs b/src/ViewModels/SearchCommitContext.cs index f81aca6317..3718b1d6a0 100644 --- a/src/ViewModels/SearchCommitContext.cs +++ b/src/ViewModels/SearchCommitContext.cs @@ -172,42 +172,28 @@ public void StartSearch() private async Task> SearchAllFieldsAsync() { + // Git ANDs `--grep`, `--author` and `--committer` within a single + // invocation, so every field needs its own `git log`. Each of those is + // a full history walk on large repositories, so they run concurrently + // instead of one after another. + var bySHA = SearchExactSHAAsync(); + var byMessage = new Commands.QueryCommits(_repo, _filter, Models.CommitSearchMethod.ByMessage, _onlySearchCurrentBranch) + .GetResultAsync(); + var byAuthor = new Commands.QueryCommits(_repo, _filter, Models.CommitSearchMethod.ByAuthor, _onlySearchCurrentBranch) + .GetResultAsync(); + var byCommitter = new Commands.QueryCommits(_repo, _filter, Models.CommitSearchMethod.ByCommitter, _onlySearchCurrentBranch) + .GetResultAsync(); + + await Task.WhenAll(bySHA, byMessage, byAuthor, byCommitter).ConfigureAwait(false); + var result = new List(); var seen = new HashSet(); - var isCommitSHA = await new Commands.IsCommitSHA(_repo, _filter) - .GetResultAsync() - .ConfigureAwait(false); + if (bySHA.Result is { } exact && seen.Add(exact.SHA)) + result.Add(exact); - if (isCommitSHA) + foreach (var matched in new[] { byMessage.Result, byAuthor.Result, byCommitter.Result }) { - var commit = await new Commands.QuerySingleCommit(_repo, _filter) - .GetResultAsync() - .ConfigureAwait(false); - - var matchesScope = commit != null; - if (matchesScope && _onlySearchCurrentBranch) - matchesScope = await new Commands.IsAncestor(_repo, commit.SHA, "HEAD") - .GetResultAsync() - .ConfigureAwait(false); - - if (matchesScope && seen.Add(commit.SHA)) - result.Add(commit); - } - - var fields = new[] - { - Models.CommitSearchMethod.ByMessage, - Models.CommitSearchMethod.ByAuthor, - Models.CommitSearchMethod.ByCommitter, - }; - - foreach (var field in fields) - { - var matched = await new Commands.QueryCommits(_repo, _filter, field, _onlySearchCurrentBranch) - .GetResultAsync() - .ConfigureAwait(false); - foreach (var c in matched) { if (seen.Add(c.SHA)) @@ -219,6 +205,29 @@ public void StartSearch() return result; } + private async Task SearchExactSHAAsync() + { + var isCommitSHA = await new Commands.IsCommitSHA(_repo, _filter) + .GetResultAsync() + .ConfigureAwait(false); + + if (!isCommitSHA) + return null; + + var commit = await new Commands.QuerySingleCommit(_repo, _filter) + .GetResultAsync() + .ConfigureAwait(false); + + if (commit == null || !_onlySearchCurrentBranch) + return commit; + + var isInCurrentBranch = await new Commands.IsAncestor(_repo, commit.SHA, "HEAD") + .GetResultAsync() + .ConfigureAwait(false); + + return isInCurrentBranch ? commit : null; + } + public void EndSearch() { if (_cancellation is { IsCancellationRequested: false })