From 1042af77ede1e397da735bd9ca2d79f56dc00cc4 Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Mon, 26 Oct 2026 14:40:55 +0100 Subject: [PATCH] Add default Mixed commit search with SHA-prefix matching --- src/Commands/QueryCommits.cs | 117 +++++++++++++-- src/Models/Commit.cs | 1 + src/Resources/Locales/en_US.axaml | 2 + src/ViewModels/SearchCommitContext.cs | 31 ++-- src/Views/CommitSearchView.axaml | 1 + .../MixedSearchChecks.csproj | 10 ++ tests/MixedSearchChecks/Program.cs | 134 ++++++++++++++++++ tests/MixedSearchChecks/README.md | 26 ++++ 8 files changed, 302 insertions(+), 20 deletions(-) create mode 100644 tests/MixedSearchChecks/MixedSearchChecks.csproj create mode 100644 tests/MixedSearchChecks/Program.cs create mode 100644 tests/MixedSearchChecks/README.md diff --git a/src/Commands/QueryCommits.cs b/src/Commands/QueryCommits.cs index 1a5073988d..e9c7457918 100644 --- a/src/Commands/QueryCommits.cs +++ b/src/Commands/QueryCommits.cs @@ -18,6 +18,18 @@ public QueryCommits(string repo, string limits, bool markMerged = true) public QueryCommits(string repo, string filter, Models.CommitSearchMethod method, bool onlyCurrentBranch) { + if (method == Models.CommitSearchMethod.Mixed) + { + WorkingDirectory = repo; + Context = repo; + _mixedFilter = filter; + // NUL-delimited fields preserve multiline messages. Limit matching results, + // rather than limiting the history before testing author/message/SHA. + Args = "log --date-order --no-show-signature --decorate=full --format=%H%x00%P%x00%D%x00%aN±%aE%x00%at%x00%cN±%cE%x00%ct%x00%s%x00%B%x00 " + + (onlyCurrentBranch ? "HEAD" : "--branches --remotes HEAD"); + return; + } + var builder = new StringBuilder(); builder.Append("log -1000 --date-order --no-show-signature --decorate=full --format=%H%x00%P%x00%D%x00%aN±%aE%x00%at%x00%cN±%cE%x00%ct%x00%s "); @@ -52,6 +64,9 @@ public QueryCommits(string repo, string filter, Models.CommitSearchMethod method public async Task> GetResultAsync() { + if (_mixedFilter != null) + return await GetMixedResultAsync().ConfigureAwait(false); + var commits = new List(); try { @@ -66,14 +81,7 @@ public QueryCommits(string repo, string filter, Models.CommitSearchMethod method if (parts.Length != 8) continue; - var commit = new Models.Commit() { SHA = parts[0] }; - commit.ParseParents(parts[1]); - commit.ParseDecorators(parts[2]); - commit.Author = Models.User.FindOrAdd(parts[3]); - commit.AuthorTime = ulong.Parse(parts[4]); - commit.Committer = Models.User.FindOrAdd(parts[5]); - commit.CommitterTime = ulong.Parse(parts[6]); - commit.Subject = parts[7]; + var commit = ParseCommit(parts); commits.Add(commit); if (!findHead && commit.IsMerged) @@ -106,6 +114,99 @@ public QueryCommits(string repo, string filter, Models.CommitSearchMethod method return commits; } + private async Task> GetMixedResultAsync() + { + var commits = new List(); + if (string.IsNullOrEmpty(_mixedFilter) || CancellationToken.IsCancellationRequested) + return commits; + + try + { + using var proc = new Process(); + proc.StartInfo = CreateGitStartInfo(true); + proc.Start(); + var errors = proc.StandardError.ReadToEndAsync(); + try + { + var fields = new string[9]; + var fieldIndex = 0; + var field = new StringBuilder(); + var buffer = new char[4096]; + var completed = false; + int count; + while (!completed && (count = await proc.StandardOutput.ReadAsync(buffer.AsMemory(), CancellationToken).ConfigureAwait(false)) > 0) + { + for (var i = 0; i < count; i++) + { + if (buffer[i] != '\0') + { + field.Append(buffer[i]); + continue; + } + + fields[fieldIndex++] = field.ToString(); + field.Clear(); + if (fieldIndex < fields.Length) + continue; + + // git inserts a newline between formatted records. + fields[0] = fields[0].TrimStart('\r', '\n'); + if (fields[0].StartsWith(_mixedFilter, StringComparison.OrdinalIgnoreCase) || + fields[3].Contains(_mixedFilter, StringComparison.OrdinalIgnoreCase) || + fields[8].Contains(_mixedFilter, StringComparison.OrdinalIgnoreCase)) + { + commits.Add(ParseCommit(fields)); + if (commits.Count == 1000) + { + completed = true; + break; + } + } + fieldIndex = 0; + } + } + } + finally + { + // Stop promptly on cancellation or when enough matches have been found. + try + { + if (!proc.HasExited) + proc.Kill(true); + } + catch (InvalidOperationException) + { + // The process can exit between HasExited and Kill. + } + await proc.WaitForExitAsync().ConfigureAwait(false); + await errors.ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // A newer search or closing the search panel cancels this read-only query. + } + catch (Exception e) + { + RaiseException($"Failed to query commits. Reason: {e.Message}"); + } + return commits; + } + + private static Models.Commit ParseCommit(string[] parts) + { + var commit = new Models.Commit() { SHA = parts[0] }; + commit.ParseParents(parts[1]); + commit.ParseDecorators(parts[2]); + commit.Author = Models.User.FindOrAdd(parts[3]); + commit.AuthorTime = ulong.Parse(parts[4]); + commit.Committer = Models.User.FindOrAdd(parts[5]); + commit.CommitterTime = ulong.Parse(parts[6]); + commit.Subject = parts[7]; + return commit; + } + + private string _mixedFilter = null; private bool _markMerged = false; } } diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index de299ed6b7..b7798f94a2 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -11,6 +11,7 @@ public enum CommitSearchMethod ByMessage, ByPath, ByContent, + Mixed, } public class Commit : ObservableObject diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 9abb3bdcc9..7ebd40f21b 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -843,6 +843,8 @@ Content Message Path + Mixed + Search SHA prefixes, author, and full commit message SHA Current Branch SEARCH BY diff --git a/src/ViewModels/SearchCommitContext.cs b/src/ViewModels/SearchCommitContext.cs index f7885e809e..37dcd05559 100644 --- a/src/ViewModels/SearchCommitContext.cs +++ b/src/ViewModels/SearchCommitContext.cs @@ -79,8 +79,7 @@ public SearchCommitContext(string repo, Histories histories) public void ClearFilter() { Filter = string.Empty; - Selected = null; - Results = null; + StartSearch(); } public void ClearSuggestions() @@ -90,6 +89,10 @@ public void ClearSuggestions() public void StartSearch() { + if (_cancellation is { IsCancellationRequested: false }) + _cancellation.Cancel(); + + IsQuerying = false; Results = null; Selected = null; Suggestions = null; @@ -99,26 +102,25 @@ public void StartSearch() IsQuerying = true; - if (_cancellation is { IsCancellationRequested: false }) - _cancellation.Cancel(); - _cancellation = new(); var token = _cancellation.Token; + var method = (Models.CommitSearchMethod)_method; + var filter = _filter; + var onlyCurrentBranch = _onlySearchCurrentBranch; Task.Run(async () => { var result = new List(); - var method = (Models.CommitSearchMethod)_method; if (method == Models.CommitSearchMethod.BySHA) { - var isCommitSHA = await new Commands.IsCommitSHA(_repo, _filter) + var isCommitSHA = await new Commands.IsCommitSHA(_repo, filter) .GetResultAsync() .ConfigureAwait(false); if (isCommitSHA) { - var commit = await new Commands.QuerySingleCommit(_repo, _filter) + var commit = await new Commands.QuerySingleCommit(_repo, filter) .GetResultAsync() .ConfigureAwait(false); @@ -129,9 +131,10 @@ public void StartSearch() result.Add(commit); } } - else if (_onlySearchCurrentBranch) + else if (onlyCurrentBranch) { - result = await new Commands.QueryCommits(_repo, _filter, method, true) + result = await new Commands.QueryCommits(_repo, filter, method, true) + .WithCancellation(token) .GetResultAsync() .ConfigureAwait(false); @@ -140,10 +143,14 @@ public void StartSearch() } else { - result = await new Commands.QueryCommits(_repo, _filter, method, false) + result = await new Commands.QueryCommits(_repo, filter, method, false) + .WithCancellation(token) .GetResultAsync() .ConfigureAwait(false); + if (token.IsCancellationRequested) + return; + if (result.Count > 0) { var set = await new Commands.QueryCurrentBranchCommitHashes(_repo, result[^1].CommitterTime) @@ -288,7 +295,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.Mixed; 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..9cac5d9772 100644 --- a/src/Views/CommitSearchView.axaml +++ b/src/Views/CommitSearchView.axaml @@ -150,6 +150,7 @@ + diff --git a/tests/MixedSearchChecks/MixedSearchChecks.csproj b/tests/MixedSearchChecks/MixedSearchChecks.csproj new file mode 100644 index 0000000000..fd214d7b72 --- /dev/null +++ b/tests/MixedSearchChecks/MixedSearchChecks.csproj @@ -0,0 +1,10 @@ + + + Exe + net10.0 + enable + + + + + diff --git a/tests/MixedSearchChecks/Program.cs b/tests/MixedSearchChecks/Program.cs new file mode 100644 index 0000000000..05bc27898d --- /dev/null +++ b/tests/MixedSearchChecks/Program.cs @@ -0,0 +1,134 @@ +using System.Diagnostics; +using System.Text; +using SourceGit.Commands; +using SourceGit.Models; +using Commit = SourceGit.Models.Commit; +using SourceGit.ViewModels; + +internal static class Program +{ + private static int _checks; + + private static async Task Main() + { + var fixture = Path.Combine(Path.GetTempPath(), "sourcegit-mixed-search-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(fixture); + SourceGit.Native.OS.GitExecutable = "git"; + try + { + Git(fixture, "init", "-b", "main"); + Git(fixture, "config", "user.name", "Alice Shared"); + Git(fixture, "config", "user.email", "alice@example.com"); + Git(fixture, "commit", "--allow-empty", "-m", "Alpha Subject Shared", "-m", "Body-only needle\nLiteral [brackets]. and a \"quote\"\nUnicode: árvíztűrő"); + Git(fixture, "commit", "--amend", "--allow-empty", "-m", "Alpha Subject Shared", "-m", "Body-only needle\nLiteral [brackets]. and a \"quote\"\nUnicode: árvíztűrő\n" + new string('x', 9000) + "long-body needle"); + var first = Git(fixture, "rev-parse", "HEAD").Trim(); + Git(fixture, "config", "user.name", "Bob"); + Git(fixture, "config", "user.email", "bob@example.com"); + Git(fixture, "commit", "--allow-empty", "-m", "Shared second"); + var second = Git(fixture, "rev-parse", "HEAD").Trim(); + Git(fixture, "checkout", "-b", "feature"); + Git(fixture, "commit", "--allow-empty", "-m", "remote-only needle"); + var remote = Git(fixture, "rev-parse", "HEAD").Trim(); + Git(fixture, "update-ref", "refs/remotes/origin/feature", remote); + Git(fixture, "checkout", "main"); + Git(fixture, "branch", "-D", "feature"); + + Check(new SearchCommitContext(fixture, null).Method == (int)CommitSearchMethod.Mixed, "Mixed is the default mode"); + Check(!(await Search(fixture, first.Substring(9, 12).ToUpperInvariant())).Any(x => x.SHA == first), "interior SHA substring does not match"); + Check((await Search(fixture, first[..12].ToUpperInvariant())).Any(x => x.SHA == first), "case-insensitive SHA prefix"); + Check((await Search(fixture, first[..4])).Any(x => x.SHA == first), "short SHA prefix"); + Check((await Search(fixture, first)).Single().SHA == first, "full SHA"); + Check((await Search(fixture, "ALICE")).Single().SHA == first, "author name"); + Check((await Search(fixture, "BOB@EXAMPLE.COM")).Count == 2, "author email across local and remote branches"); + Check((await Search(fixture, "alpha subject")).Single().SHA == first, "subject match"); + Check((await Search(fixture, "body-only NEEDLE")).Single().SHA == first, "full-message body match"); + Check((await Search(fixture, "[brackets].")).Single().SHA == first, "punctuation is literal"); + Check((await Search(fixture, "\"quote\"")).Single().SHA == first, "quote is literal"); + Check((await Search(fixture, "ÁRVÍZTŰRŐ")).Single().SHA == first, "Unicode case-insensitive match"); + Check((await Search(fixture, "long-body needle")).Single().SHA == first, "message matching spans stream buffers"); + var shared = await Search(fixture, "shared"); + Check(shared.Count == 2 && shared.Any(x => x.SHA == first) && shared.Any(x => x.SHA == second), "author OR message matches"); + Check((await Search(fixture, "remote-only")).Single().SHA == remote, "remote branch scope"); + Check((await Search(fixture, "remote-only", true)).Count == 0, "current-branch scope excludes remote-only commits"); + Check((await Search(fixture, "nonexistent needle")).Count == 0, "no matches"); + Check((await Search(fixture, "")).Count == 0, "empty filter"); + Check((await new QueryCommits(fixture, "Alpha", CommitSearchMethod.ByMessage, true).GetResultAsync()).Single().SHA == first, "existing Message mode"); + Check((await new QueryCommits(fixture, "Alice", CommitSearchMethod.ByAuthor, true).GetResultAsync()).Single().SHA == first, "existing Author mode"); + + Git(fixture, "checkout", "--detach"); + Git(fixture, "commit", "--allow-empty", "-m", "detached-only needle"); + Check((await Search(fixture, "detached-only")).Count == 1, "all-branches scope includes detached HEAD"); + Check((await Search(fixture, "detached-only", true)).Count == 1, "current-branch scope includes detached HEAD"); + Git(fixture, "checkout", "main"); + ImportBulk(fixture, second); + Check((await Search(fixture, "Alpha Subject")).Single().SHA == first, "message match older than newest 1000 commits"); + Check((await Search(fixture, first[..12])).Any(x => x.SHA == first), "SHA prefix match older than newest 1000 commits"); + var bulk = await Search(fixture, "bulk-match", true); + Check(bulk.Count == 1000 && bulk[0].Subject == "bulk-match 1004" && bulk[^1].Subject == "bulk-match 5", "limit applies to matches in history order"); + Check(bulk.Select(x => x.SHA).Distinct().Count() == bulk.Count, "results contain no duplicates"); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + Check((await new QueryCommits(fixture, "bulk", CommitSearchMethod.Mixed, false) { CancellationToken = cancelled.Token }.GetResultAsync()).Count == 0, "cancelled query exits without results"); + using var running = new CancellationTokenSource(); + var query = new QueryCommits(fixture, "not found", CommitSearchMethod.Mixed, false) { CancellationToken = running.Token }.GetResultAsync(); + running.Cancel(); + await query.WaitAsync(TimeSpan.FromSeconds(5)); + Check(true, "active query cancellation completes promptly"); + var unborn = Path.Combine(fixture, "unborn"); + Directory.CreateDirectory(unborn); + Git(unborn, "init", "-b", "main"); + Check((await Search(unborn, "needle")).Count == 0, "repository without commits returns no results"); + Console.WriteLine($"PASS: {_checks} mixed-search checks"); + } + finally + { + foreach (var file in Directory.EnumerateFiles(fixture, "*", SearchOption.AllDirectories)) + File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(fixture, true); + } + } + + private static Task> Search(string fixture, string filter, bool current = false) => + new QueryCommits(fixture, filter, CommitSearchMethod.Mixed, current).GetResultAsync(); + + private static void ImportBulk(string fixture, string parent) + { + var start = new ProcessStartInfo("git") { WorkingDirectory = fixture, UseShellExecute = false, + CreateNoWindow = true, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, + StandardInputEncoding = new UTF8Encoding(false) }; + start.ArgumentList.Add("fast-import"); + start.ArgumentList.Add("--quiet"); + using var process = Process.Start(start); + for (var i = 0; i < 1005; i++) + { + var message = "bulk-match " + i; + var from = i == 0 ? parent : ":" + i; + process.StandardInput.Write($"commit refs/heads/main\nmark :{i + 1}\ncommitter Bulk {1800000000 + i} +0000\ndata {Encoding.UTF8.GetByteCount(message)}\n{message}\nfrom {from}\n\n"); + } + process.StandardInput.Close(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new Exception(process.StandardError.ReadToEnd()); + } + + private static string Git(string fixture, params string[] args) + { + var start = new ProcessStartInfo("git") { WorkingDirectory = fixture, UseShellExecute = false, + CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, StandardOutputEncoding = Encoding.UTF8 }; + foreach (var arg in args) + start.ArgumentList.Add(arg); + using var process = Process.Start(start); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new Exception(process.StandardError.ReadToEnd()); + return output; + } + + private static void Check(bool condition, string name) + { + if (!condition) + throw new Exception("FAIL: " + name); + _checks++; + } +} diff --git a/tests/MixedSearchChecks/README.md b/tests/MixedSearchChecks/README.md new file mode 100644 index 0000000000..4b12a88f9e --- /dev/null +++ b/tests/MixedSearchChecks/README.md @@ -0,0 +1,26 @@ +# Mixed commit-search checks + +This standalone executable uses an isolated temporary Git repository to exercise +Mixed search against real Git output. It requires Git and the application build +prerequisites, and cleans up its own fixture on success or failure. + +```powershell +dotnet build tests/MixedSearchChecks/MixedSearchChecks.csproj -o build/mixed-search-checks +./build/mixed-search-checks/MixedSearchChecks.exe +``` + +Use a separate output folder if a running SourceGit instance locks the normal +build output. In restricted environments, add `-p:UsedAvaloniaProducts=` to disable +the build-services telemetry task. Remove the temporary output after validation. + +Mixed uses case-insensitive SHA prefix matching and literal substring matching +for author name/email and the full commit message, returning a commit if any field +matches. Results follow Git history order and are limited to 1,000 matches. +The current-branch option restricts searches to HEAD's reachable history; +otherwise local branches, remote branches, and HEAD are included. + +Coverage includes default mode selection, SHA prefixes and rejection of interior SHA substrings, +author/message OR matching, multiline/long messages, Unicode and punctuation, +branch scoping and detached HEAD, deduplication, matches older than 1,000 commits, +the matching-result limit, cancellation, empty repositories, and existing Author +and Message modes.