refactor: rewrite file-history to support detecting renames (--follow) (#2174)

Signed-off-by: leo <longshuang@msn.cn>
This commit is contained in:
leo
2026-03-10 13:56:55 +08:00
parent 9a8498b9d2
commit 7bf78a9610
6 changed files with 210 additions and 73 deletions

View File

@@ -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<List<Models.FileVersion>> GetResultAsync()
{
var versions = new List<Models.FileVersion>();
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;
}
}
}

View File

@@ -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;
}
/// <summary>
/// Used to diff in `FileHistory`
/// </summary>
/// <param name="ver"></param>
public DiffOption(FileVersion ver)
{
_revisions.Add(ver.HasParent ? $"{ver.SHA}^" : Commit.EmptyTreeSHA1);
_revisions.Add(ver.SHA);
_path = ver.Path;
_orgPath = ver.Change.OriginalPath;
}
/// <summary>
/// Used to diff two revisions in `FileHistory`
/// </summary>
/// <param name="start"></param>
/// <param name="end"></param>
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;
}
}
/// <summary>
/// Used to show differences between two revisions.
/// </summary>
@@ -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<string> _revisions = [];
private readonly bool _ignorePaths = false;
}
}

18
src/Models/FileVersion.cs Normal file
View File

@@ -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);
}
}

View File

@@ -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<bool> 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<Models.Change> _changes = [];
private DiffContext _viewContent = null;
}
@@ -246,13 +225,13 @@ namespace SourceGit.ViewModels
private set => SetProperty(ref _isLoading, value);
}
public List<Models.Commit> Commits
public List<Models.FileVersion> Revisions
{
get => _commits;
set => SetProperty(ref _commits, value);
get => _revisions;
set => SetProperty(ref _revisions, value);
}
public AvaloniaList<Models.Commit> SelectedCommits
public AvaloniaList<Models.FileVersion> 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<Models.Commit> _commits = null;
private List<Models.FileVersion> _revisions = null;
private Dictionary<string, string> _fullCommitMessages = new();
private object _viewContent = null;
}

View File

@@ -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 @@
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate DataType="m:Commit">
<DataTemplate DataType="m:FileVersion">
<Border BorderBrush="{DynamicResource Brush.Border2}" BorderThickness="0,0,0,1" Padding="4">
<Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,96">
@@ -196,16 +196,13 @@
<Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" Margin="4,6" ColumnDefinitions="*,32,*,Auto">
<Grid.DataTemplates>
<DataTemplate DataType="m:Commit">
<DataTemplate DataType="m:FileVersion">
<Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<v:Avatar Width="16" Height="16" VerticalAlignment="Center" IsHitTestVisible="False" User="{Binding Author}"/>
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,Auto">
<v:Avatar Grid.Column="0" Width="16" Height="16" VerticalAlignment="Center" IsHitTestVisible="False" User="{Binding Author}"/>
<TextBlock Grid.Column="1" Text="{Binding Author.Name}" Margin="8,0,0,0"/>
<Border Grid.Column="2" Background="{DynamicResource Brush.Accent}" CornerRadius="4" IsVisible="{Binding IsCurrentHead}">
<TextBlock Text="HEAD" Margin="4,0" Foreground="#FFDDDDDD"/>
</Border>
<TextBlock Grid.Column="3" Text="{Binding SHA, Converter={x:Static c:StringConverters.ToShortSHA}}" Foreground="DarkOrange" Margin="8,0,0,0" TextDecorations="Underline" Cursor="Hand" PointerPressed="OnPressCommitSHA" />
<TextBlock Grid.Column="4" Text="{Binding CommitterTimeStr}" Foreground="{DynamicResource Brush.FG2}" Margin="8,0,0,0"/>
<TextBlock Grid.Column="2" Text="{Binding SHA, Converter={x:Static c:StringConverters.ToShortSHA}}" Foreground="DarkOrange" Margin="8,0,0,0" TextDecorations="Underline" Cursor="Hand" PointerPressed="OnPressCommitSHA" />
<TextBlock Grid.Column="3" Text="{Binding AuthorTimeStr}" Foreground="{DynamicResource Brush.FG2}" Margin="8,0,0,0"/>
</Grid>
<TextBlock Grid.Row="1" Text="{Binding Subject}" VerticalAlignment="Bottom"/>

View File

@@ -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));
}
}