From 7bf78a961044d50d1b1fda640d3f32f116c573bb Mon Sep 17 00:00:00 2001 From: leo Date: Tue, 10 Mar 2026 13:56:55 +0800 Subject: [PATCH] refactor: rewrite file-history to support detecting renames (`--follow`) (#2174) Signed-off-by: leo --- src/Commands/QueryFileHistory.cs | 98 ++++++++++++++++++++++++++++++++ src/Models/DiffOption.cs | 54 +++++++++++++++++- src/Models/FileVersion.cs | 18 ++++++ src/ViewModels/FileHistories.cs | 86 ++++++++++------------------ src/Views/FileHistories.axaml | 19 +++---- src/Views/FileHistories.axaml.cs | 8 +-- 6 files changed, 210 insertions(+), 73 deletions(-) create mode 100644 src/Commands/QueryFileHistory.cs create mode 100644 src/Models/FileVersion.cs diff --git a/src/Commands/QueryFileHistory.cs b/src/Commands/QueryFileHistory.cs new file mode 100644 index 00000000..3c2b4a66 --- /dev/null +++ b/src/Commands/QueryFileHistory.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public partial class QueryFileHistory : Command + { + [GeneratedRegex(@"^([MADC])\s+(.+)$")] + private static partial Regex REG_FORMAT(); + [GeneratedRegex(@"^R[0-9]{0,4}\s+(.+)$")] + private static partial Regex REG_RENAME_FORMAT(); + + public QueryFileHistory(string repo, string path, string head) + { + WorkingDirectory = repo; + Context = repo; + RaiseError = false; + + var builder = new StringBuilder(); + builder.Append("log --follow --no-show-signature --date-order -n 10000 --decorate=no --format=\"@%H%x00%P%x00%aN±%aE%x00%at%x00%s\" --name-status "); + if (!string.IsNullOrEmpty(head)) + builder.Append(head).Append(" "); + builder.Append("-- ").Append(path.Quoted()); + + Args = builder.ToString(); + } + + public async Task> GetResultAsync() + { + var versions = new List(); + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return versions; + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + if (lines.Length == 0) + return versions; + + Models.FileVersion last = null; + foreach (var line in lines) + { + if (line.StartsWith('@')) + { + var parts = line.Split('\0'); + if (parts.Length != 5) + continue; + + last = new Models.FileVersion(); + last.SHA = parts[0].Substring(1); + last.HasParent = !string.IsNullOrEmpty(parts[1]); + last.Author = Models.User.FindOrAdd(parts[2]); + last.AuthorTime = ulong.Parse(parts[3]); + last.Subject = parts[4]; + versions.Add(last); + } + else if (last != null) + { + var match = REG_FORMAT().Match(line); + if (!match.Success) + { + match = REG_RENAME_FORMAT().Match(line); + if (match.Success) + { + last.Change.Path = match.Groups[1].Value; + last.Change.Set(Models.ChangeState.Renamed); + } + + continue; + } + + last.Change.Path = match.Groups[2].Value; + + var status = match.Groups[1].Value; + switch (status[0]) + { + case 'M': + last.Change.Set(Models.ChangeState.Modified); + break; + case 'A': + last.Change.Set(Models.ChangeState.Added); + break; + case 'D': + last.Change.Set(Models.ChangeState.Deleted); + break; + case 'C': + last.Change.Set(Models.ChangeState.Copied); + break; + } + } + } + + return versions; + } + } +} diff --git a/src/Models/DiffOption.cs b/src/Models/DiffOption.cs index 2ecfe458..6a9f400a 100644 --- a/src/Models/DiffOption.cs +++ b/src/Models/DiffOption.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Text; namespace SourceGit.Models @@ -79,6 +80,53 @@ namespace SourceGit.Models _path = file; } + /// + /// Used to diff in `FileHistory` + /// + /// + public DiffOption(FileVersion ver) + { + _revisions.Add(ver.HasParent ? $"{ver.SHA}^" : Commit.EmptyTreeSHA1); + _revisions.Add(ver.SHA); + _path = ver.Path; + _orgPath = ver.Change.OriginalPath; + } + + /// + /// Used to diff two revisions in `FileHistory` + /// + /// + /// + public DiffOption(FileVersion start, FileVersion end) + { + if (start.Change.Index == ChangeState.Deleted) + { + _revisions.Add(Commit.EmptyTreeSHA1); + _revisions.Add(end.SHA); + _path = end.Path; + } + else if (end.Change.Index == ChangeState.Deleted) + { + _revisions.Add(start.SHA); + _revisions.Add(Commit.EmptyTreeSHA1); + _path = start.Path; + } + else if (!end.Path.Equals(start.Path, StringComparison.Ordinal)) + { + _revisions.Add($"{start.SHA}:{start.Path.Quoted()}"); + _revisions.Add($"{end.SHA}:{end.Path.Quoted()}"); + _path = end.Path; + _orgPath = start.Path; + _ignorePaths = true; + } + else + { + _revisions.Add(start.SHA); + _revisions.Add(end.SHA); + _path = start.Path; + } + } + /// /// Used to show differences between two revisions. /// @@ -104,6 +152,9 @@ namespace SourceGit.Models foreach (var r in _revisions) builder.Append($"{r} "); + if (_ignorePaths) + return builder.ToString(); + builder.Append("-- "); if (!string.IsNullOrEmpty(_orgPath)) builder.Append($"{_orgPath.Quoted()} "); @@ -118,5 +169,6 @@ namespace SourceGit.Models private readonly string _orgPath = string.Empty; private readonly string _extra = string.Empty; private readonly List _revisions = []; + private readonly bool _ignorePaths = false; } } diff --git a/src/Models/FileVersion.cs b/src/Models/FileVersion.cs new file mode 100644 index 00000000..30d5c9c0 --- /dev/null +++ b/src/Models/FileVersion.cs @@ -0,0 +1,18 @@ +using System; + +namespace SourceGit.Models +{ + public class FileVersion + { + public string SHA { get; set; } = string.Empty; + public bool HasParent { get; set; } = false; + public User Author { get; set; } = User.Invalid; + public ulong AuthorTime { get; set; } = 0; + public string Subject { get; set; } = string.Empty; + public Change Change { get; set; } = new(); + + public string Path => Change.Path; + public string AuthorTimeStr => DateTime.UnixEpoch.AddSeconds(AuthorTime).ToLocalTime().ToString(DateTimeFormat.Active.DateTime); + public string AuthorTimeShortStr => DateTime.UnixEpoch.AddSeconds(AuthorTime).ToLocalTime().ToString(DateTimeFormat.Active.DateOnly); + } +} diff --git a/src/ViewModels/FileHistories.cs b/src/ViewModels/FileHistories.cs index 91cd5623..6dfcae12 100644 --- a/src/ViewModels/FileHistories.cs +++ b/src/ViewModels/FileHistories.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Text; using System.Threading.Tasks; using Avalonia.Collections; @@ -36,10 +35,10 @@ namespace SourceGit.ViewModels set => SetProperty(ref _viewContent, value); } - public FileHistoriesSingleRevision(string repo, string file, Models.Commit revision, bool prevIsDiffMode) + public FileHistoriesSingleRevision(string repo, Models.FileVersion revision, bool prevIsDiffMode) { _repo = repo; - _file = file; + _file = revision.Path; _revision = revision; _isDiffMode = prevIsDiffMode; _viewContent = null; @@ -155,26 +154,25 @@ namespace SourceGit.ViewModels private void SetViewContentAsDiff() { - var option = new Models.DiffOption(_revision, _file); - ViewContent = new DiffContext(_repo, option, _viewContent as DiffContext); + ViewContent = new DiffContext(_repo, new Models.DiffOption(_revision), _viewContent as DiffContext); } private string _repo = null; private string _file = null; - private Models.Commit _revision = null; + private Models.FileVersion _revision = null; private bool _isDiffMode = false; private object _viewContent = null; } public class FileHistoriesCompareRevisions : ObservableObject { - public Models.Commit StartPoint + public Models.FileVersion StartPoint { get => _startPoint; set => SetProperty(ref _startPoint, value); } - public Models.Commit EndPoint + public Models.FileVersion EndPoint { get => _endPoint; set => SetProperty(ref _endPoint, value); @@ -186,19 +184,18 @@ namespace SourceGit.ViewModels set => SetProperty(ref _viewContent, value); } - public FileHistoriesCompareRevisions(string repo, string file, Models.Commit start, Models.Commit end) + public FileHistoriesCompareRevisions(string repo, Models.FileVersion start, Models.FileVersion end) { _repo = repo; - _file = file; _startPoint = start; _endPoint = end; - RefreshViewContent(); + _viewContent = new(_repo, new(start, end)); } public void Swap() { (StartPoint, EndPoint) = (_endPoint, _startPoint); - RefreshViewContent(); + ViewContent = new(_repo, new(_startPoint, _endPoint), _viewContent); } public async Task SaveAsPatch(string saveTo) @@ -208,27 +205,9 @@ namespace SourceGit.ViewModels .ConfigureAwait(false); } - private void RefreshViewContent() - { - Task.Run(async () => - { - _changes = await new Commands.CompareRevisions(_repo, _startPoint.SHA, _endPoint.SHA, _file).ReadAsync().ConfigureAwait(false); - if (_changes.Count == 0) - { - Dispatcher.UIThread.Post(() => ViewContent = null); - } - else - { - var option = new Models.DiffOption(_startPoint.SHA, _endPoint.SHA, _changes[0]); - Dispatcher.UIThread.Post(() => ViewContent = new DiffContext(_repo, option, _viewContent)); - } - }); - } - private string _repo = null; - private string _file = null; - private Models.Commit _startPoint = null; - private Models.Commit _endPoint = null; + private Models.FileVersion _startPoint = null; + private Models.FileVersion _endPoint = null; private List _changes = []; private DiffContext _viewContent = null; } @@ -246,13 +225,13 @@ namespace SourceGit.ViewModels private set => SetProperty(ref _isLoading, value); } - public List Commits + public List Revisions { - get => _commits; - set => SetProperty(ref _commits, value); + get => _revisions; + set => SetProperty(ref _revisions, value); } - public AvaloniaList SelectedCommits + public AvaloniaList SelectedRevisions { get; set; @@ -275,41 +254,34 @@ namespace SourceGit.ViewModels Task.Run(async () => { - var argsBuilder = new StringBuilder(); - argsBuilder - .Append("--date-order -n 10000 ") - .Append(commit ?? string.Empty) - .Append(" -- ") - .Append(file.Quoted()); - - var commits = await new Commands.QueryCommits(_repo, argsBuilder.ToString(), false) + var revisions = await new Commands.QueryFileHistory(_repo, file, commit) .GetResultAsync() .ConfigureAwait(false); Dispatcher.UIThread.Post(() => { IsLoading = false; - Commits = commits; - if (Commits.Count > 0) - SelectedCommits.Add(Commits[0]); + Revisions = revisions; + if (revisions.Count > 0) + SelectedRevisions.Add(revisions[0]); }); }); - SelectedCommits.CollectionChanged += (_, _) => + SelectedRevisions.CollectionChanged += (_, _) => { if (_viewContent is FileHistoriesSingleRevision singleRevision) _prevIsDiffMode = singleRevision.IsDiffMode; - ViewContent = SelectedCommits.Count switch + ViewContent = SelectedRevisions.Count switch { - 1 => new FileHistoriesSingleRevision(_repo, file, SelectedCommits[0], _prevIsDiffMode), - 2 => new FileHistoriesCompareRevisions(_repo, file, SelectedCommits[0], SelectedCommits[1]), - _ => SelectedCommits.Count, + 1 => new FileHistoriesSingleRevision(_repo, SelectedRevisions[0], _prevIsDiffMode), + 2 => new FileHistoriesCompareRevisions(_repo, SelectedRevisions[0], SelectedRevisions[1]), + _ => SelectedRevisions.Count, }; }; } - public void NavigateToCommit(Models.Commit commit) + public void NavigateToCommit(Models.FileVersion revision) { var launcher = App.GetLauncher(); if (launcher != null) @@ -318,16 +290,16 @@ namespace SourceGit.ViewModels { if (page.Data is Repository repo && repo.FullPath.Equals(_repo, StringComparison.Ordinal)) { - repo.NavigateToCommit(commit.SHA); + repo.NavigateToCommit(revision.SHA); break; } } } } - public string GetCommitFullMessage(Models.Commit commit) + public string GetCommitFullMessage(Models.FileVersion revision) { - var sha = commit.SHA; + var sha = revision.SHA; if (_fullCommitMessages.TryGetValue(sha, out var msg)) return msg; @@ -339,7 +311,7 @@ namespace SourceGit.ViewModels private readonly string _repo = null; private bool _isLoading = true; private bool _prevIsDiffMode = true; - private List _commits = null; + private List _revisions = null; private Dictionary _fullCommitMessages = new(); private object _viewContent = null; } diff --git a/src/Views/FileHistories.axaml b/src/Views/FileHistories.axaml index 11d28996..15ee1a44 100644 --- a/src/Views/FileHistories.axaml +++ b/src/Views/FileHistories.axaml @@ -59,8 +59,8 @@ BorderThickness="1" Margin="8,4,4,8" BorderBrush="{DynamicResource Brush.Border2}" - ItemsSource="{Binding Commits}" - SelectedItems="{Binding SelectedCommits, Mode=TwoWay}" + ItemsSource="{Binding Revisions}" + SelectedItems="{Binding SelectedRevisions, Mode=TwoWay}" SelectionMode="Multiple" ScrollViewer.HorizontalScrollBarVisibility="Disabled" ScrollViewer.VerticalScrollBarVisibility="Auto"> @@ -79,7 +79,7 @@ - + @@ -196,16 +196,13 @@ - + - - + + - - - - - + + diff --git a/src/Views/FileHistories.axaml.cs b/src/Views/FileHistories.axaml.cs index 793fd411..749b4862 100644 --- a/src/Views/FileHistories.axaml.cs +++ b/src/Views/FileHistories.axaml.cs @@ -16,10 +16,10 @@ namespace SourceGit.Views private void OnPressCommitSHA(object sender, PointerPressedEventArgs e) { - if (sender is TextBlock { DataContext: Models.Commit commit } && + if (sender is TextBlock { DataContext: Models.FileVersion ver } && DataContext is ViewModels.FileHistories vm) { - vm.NavigateToCommit(commit); + vm.NavigateToCommit(ver); } e.Handled = true; @@ -76,12 +76,12 @@ namespace SourceGit.Views private void OnCommitSubjectPointerMoved(object sender, PointerEventArgs e) { - if (sender is Border { DataContext: Models.Commit commit } border && + if (sender is Border { DataContext: Models.FileVersion ver } border && DataContext is ViewModels.FileHistories vm) { var tooltip = ToolTip.GetTip(border); if (tooltip == null) - ToolTip.SetTip(border, vm.GetCommitFullMessage(commit)); + ToolTip.SetTip(border, vm.GetCommitFullMessage(ver)); } }