Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions src/Commands/QuerySubmoduleHistory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace SourceGit.Commands
{
// All commands are read-only and use argument lists (paths may contain spaces or quotes).
public class QuerySubmoduleHistory(string repository, CancellationToken cancellationToken = default)
{
public async Task<List<Models.SubmoduleHistory>> GetChangesAsync(Models.Commit commit)
{
var current = await ReadLinksAsync(commit.SHA).ConfigureAwait(false);
var parents = new List<Dictionary<string, string>>();
foreach (var parent in commit.Parents)
parents.Add(await ReadLinksAsync(parent).ConfigureAwait(false));

var paths = current.Keys.Concat(parents.SelectMany(x => x.Keys)).Distinct().Order(StringComparer.Ordinal);
var result = new List<Models.SubmoduleHistory>();
foreach (var path in paths)
{
current.TryGetValue(path, out var next);
if (parents.Count > 0 && parents.All(p => p.GetValueOrDefault(path) == next))
continue;

var section = new Models.SubmoduleHistory { OwnerSHA = commit.SHA, Path = path, NewSHA = next };
for (var i = 0; i < parents.Count; i++)
{
if (!parents[i].TryGetValue(path, out var old) || section.Previous.ContainsKey(old))
continue;
var previousChange = await GitAsync(repository, ["log", "-1", "--format=%H", commit.Parents[i], "--", path]).ConfigureAwait(false);
section.Previous[old] = previousChange.Trim();
}
result.Add(section);
}
return result;
}

public async Task LoadAsync(Models.SubmoduleHistory section, Models.SubmoduleHistoryMode mode, int limit)
{
section.Commits = [];
section.Error = string.Empty;
section.IsTruncated = false;
try
{
var root = System.IO.Path.GetFullPath(repository);
var path = System.IO.Path.GetFullPath(System.IO.Path.Combine(root, section.Path));
if (!path.StartsWith(root + System.IO.Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
!(File.Exists(System.IO.Path.Combine(path, ".git")) || Directory.Exists(System.IO.Path.Combine(path, ".git"))))
throw new InvalidOperationException("SubmoduleHistory.Unavailable");

section.RepositoryPath = path;
var tips = section.Previous.Keys.ToList();
if (section.NewSHA != null)
tips.Insert(0, section.NewSHA);
foreach (var tip in tips.Distinct())
await GitAsync(path, ["cat-file", "-e", tip + "^{commit}"]).ConfigureAwait(false);

limit = Math.Max(1, limit);
var args = new List<string> { "log", "--topo-order", "--no-show-signature", "--format=%H%x00%P%x00%aN±%aE%x00%at%x00%cN±%cE%x00%ct%x00%s", $"--max-count={limit + 1}" };
if (mode == Models.SubmoduleHistoryMode.FullHistory)
{
args.AddRange(tips.Distinct());
}
else if (section.NewSHA != null)
{
args.Add(section.NewSHA);
if (section.Previous.Count > 0)
{
args.Add("--not");
args.AddRange(section.Previous.Keys);
}
}
else
{
args.Add("--no-walk");
args.AddRange(tips.Distinct());
}
args.Add("--");
var commits = ParseLog(await GitAsync(path, args).ConfigureAwait(false));
section.IsTruncated = commits.Count > limit;
if (section.IsTruncated)
commits.RemoveAt(commits.Count - 1);

if (mode == Models.SubmoduleHistoryMode.ChangedRange)
{
// Include the old pointer as an explicit boundary, also for rewinds.
foreach (var old in section.Previous.Keys)
{
if (commits.Any(c => c.SHA == old))
continue;
var boundary = await GitAsync(path, ["log", "-1", "--no-show-signature", "--format=%H%x00%P%x00%aN±%aE%x00%at%x00%cN±%cE%x00%ct%x00%s", old, "--"]).ConfigureAwait(false);
commits.AddRange(ParseLog(boundary));
}
if (section.NewSHA != null && commits.All(c => c.SHA != section.NewSHA))
{
var tip = await GitAsync(path, ["log", "-1", "--no-show-signature", "--format=%H%x00%P%x00%aN±%aE%x00%at%x00%cN±%cE%x00%ct%x00%s", section.NewSHA, "--"]).ConfigureAwait(false);
commits.AddRange(ParseLog(tip));
}
}
section.Commits = commits;
}
catch (OperationCanceledException) { throw; }
catch (Exception e)
{
section.Error = e is InvalidOperationException && e.Message == "SubmoduleHistory.Unavailable"
? "SubmoduleHistory.Unavailable" : "SubmoduleHistory.MissingObjects";
}
}

public Task<string> ReadCommitAsync(string path, string sha) =>
GitAsync(path, ["show", "--no-ext-diff", "--no-textconv", "--no-show-signature", "--format=fuller", "--stat", sha, "--"]);

private async Task<Dictionary<string, string>> ReadLinksAsync(string revision)
{
var output = await GitAsync(repository, ["ls-tree", "-r", "-z", revision]).ConfigureAwait(false);
var links = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var entry in output.Split('\0', StringSplitOptions.RemoveEmptyEntries))
{
var tab = entry.IndexOf('\t');
if (tab > 0 && entry.StartsWith("160000 commit ", StringComparison.Ordinal))
links[entry[(tab + 1)..]] = entry[14..tab];
}
return links;
}

private static List<Models.Commit> ParseLog(string text)
{
var result = new List<Models.Commit>();
foreach (var line in text.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var fields = line.Split('\0');
if (fields.Length != 7)
continue;
var commit = new Models.Commit
{
SHA = fields[0],
Author = Models.User.FindOrAdd(fields[2]),
AuthorTime = ulong.Parse(fields[3]),
Committer = Models.User.FindOrAdd(fields[4]),
CommitterTime = ulong.Parse(fields[5]),
Subject = fields[6].TrimEnd('\r'),
};
commit.ParseParents(fields[1]);
result.Add(commit);
}
return result;
}

private async Task<string> GitAsync(string path, IEnumerable<string> args)
{
cancellationToken.ThrowIfCancellationRequested();
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = string.IsNullOrEmpty(Native.OS.GitExecutable) ? "git" : Native.OS.GitExecutable,
WorkingDirectory = path,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
process.StartInfo.ArgumentList.Add("--no-pager");
foreach (var arg in args)
process.StartInfo.ArgumentList.Add(arg);
process.Start();
using var registration = cancellationToken.Register(() =>
{
try { if (!process.HasExited) process.Kill(true); }
catch (InvalidOperationException) { }
});
var output = process.StandardOutput.ReadToEndAsync(cancellationToken);
var error = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
await Task.WhenAll(output, error).ConfigureAwait(false);
if (process.ExitCode != 0)
throw new InvalidOperationException(await error.ConfigureAwait(false));
return await output.ConfigureAwait(false);
}
}
}
5 changes: 5 additions & 0 deletions src/Models/Commit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ public class Commit : ObservableObject
public List<string> Parents { get; set; } = new();
public List<Decorator> Decorators { get; set; } = new();

public SubmoduleHistory SubmoduleHistory { get; set; }
public string GraphKey => SubmoduleHistory?.GraphKey(SHA) ?? SHA;
public List<string> GraphParents { get; set; }
public List<string> DisplayParents => GraphParents ?? Parents;

public bool IsMerged { get; set; } = false;
public int Color { get; set; } = 0;
public double LeftMargin { get; set; } = 0;
Expand Down
30 changes: 15 additions & 15 deletions src/Models/CommitGraph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,17 @@ public static CommitGraph Generate(List<Commit> commits, bool firstParentOnlyEna
var isHighlighted = defHighlighting;
foreach (var l in unsolved)
{
if (l.Next.Equals(commit.SHA, StringComparison.Ordinal))
if (l.Next.Equals(commit.GraphKey, StringComparison.Ordinal))
{
if (major == null)
{
offsetX += unitWidth;
major = l;
isHighlighted = major.IsHighlighted;

if (commit.Parents.Count > 0)
if (commit.DisplayParents.Count > 0)
{
major.Next = commit.Parents[0];
major.Next = commit.DisplayParents[0];
major.Goto(offsetX, offsetY, halfHeight);
}
else
Expand Down Expand Up @@ -151,25 +151,25 @@ public static CommitGraph Generate(List<Commit> commits, bool firstParentOnlyEna
break;
case CommitGraphHighlighting.SelectedCommitsOnly:
case CommitGraphHighlighting.SelectedCommitsOnlyFirstParent:
if (highlightExtraCommits.Remove(commit.SHA))
if (highlightExtraCommits.Remove(commit.GraphKey))
{
isHighlighted = true;
// Highlight first parent, other parents are dealt with later
if (commit.Parents.Count > 0)
highlightExtraCommits.Add(commit.Parents[0]);
if (commit.DisplayParents.Count > 0)
highlightExtraCommits.Add(commit.DisplayParents[0]);
}
break;
default: // CommitGraphHighlighting.CurrentBranchAndSelectedCommits
if (commit.IsMerged)
{
isHighlighted = true;
}
else if (highlightExtraCommits.Remove(commit.SHA))
else if (highlightExtraCommits.Remove(commit.GraphKey))
{
isHighlighted = true;
// Highlight first parent, other parents are dealt with later
if (commit.Parents.Count > 0)
highlightExtraCommits.Add(commit.Parents[0]);
if (commit.DisplayParents.Count > 0)
highlightExtraCommits.Add(commit.DisplayParents[0]);
}
break;
}
Expand All @@ -182,14 +182,14 @@ public static CommitGraph Generate(List<Commit> commits, bool firstParentOnlyEna
{
offsetX += unitWidth;

if (commit.Parents.Count > 0)
if (commit.DisplayParents.Count > 0)
{
major = new PathHelper(commit.Parents[0], isHighlighted, colorPicker.Next(), new Point(offsetX, offsetY));
major = new PathHelper(commit.DisplayParents[0], isHighlighted, colorPicker.Next(), new Point(offsetX, offsetY));
unsolved.Add(major);
temp.Paths.Add(major.Path);
}
}
else if (isHighlighted && !major.IsHighlighted && commit.Parents.Count > 0)
else if (isHighlighted && !major.IsHighlighted && commit.DisplayParents.Count > 0)
{
major.Highlight();
temp.Paths.Add(major.Path);
Expand All @@ -201,7 +201,7 @@ public static CommitGraph Generate(List<Commit> commits, bool firstParentOnlyEna
var anchor = new Dot() { Center = position, Color = dotColor, IsHighlighted = isHighlighted };
if (commit.IsCurrentHead)
anchor.Type = DotType.Head;
else if (commit.Parents.Count > 1)
else if (commit.DisplayParents.Count > 1)
anchor.Type = DotType.Merge;
else
anchor.Type = DotType.Default;
Expand All @@ -213,9 +213,9 @@ public static CommitGraph Generate(List<Commit> commits, bool firstParentOnlyEna
if (highlighting == CommitGraphHighlighting.SelectedCommitsOnlyFirstParent)
isHighlighted = false;

for (int j = 1; j < commit.Parents.Count; j++)
for (int j = 1; j < commit.DisplayParents.Count; j++)
{
var parentHash = commit.Parents[j];
var parentHash = commit.DisplayParents[j];
var parent = unsolved.Find(x => x.Next.Equals(parentHash, StringComparison.Ordinal));
if (parent != null)
{
Expand Down
86 changes: 86 additions & 0 deletions src/Models/SubmoduleHistory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace SourceGit.Models
{
public enum SubmoduleHistoryMode
{
ChangedRange,
FullHistory,
}

public class SubmoduleHistory
{
public string OwnerSHA { get; init; }
public string Path { get; init; }
public string RepositoryPath { get; set; }
public string NewSHA { get; init; }
public Dictionary<string, string> Previous { get; } = new();
public List<Commit> Commits { get; set; } = [];
public string Error { get; set; } = string.Empty;
public bool IsTruncated { get; set; }
public string Key => OwnerSHA + ":" + Path;
public string GraphKey(string sha) => Key + ":" + sha;

// Build display-only edges. Never change the commits returned by Git.
public static List<Commit> Compose(List<Commit> roots, IReadOnlyList<SubmoduleHistory> expanded)
{
var result = new List<Commit>();
var rootPositions = roots.Select((c, i) => (c.SHA, i)).ToDictionary(x => x.SHA, x => x.i);
foreach (var root in roots)
{
var sections = expanded.Where(x => x.OwnerSHA == root.SHA && x.Commits.Count > 0).ToList();
if (sections.Count == 0)
{
result.Add(root);
continue;
}

var anchor = Copy(root);
anchor.GraphParents = new(root.Parents);
result.Add(anchor);
foreach (var section in sections)
{
var visible = section.Commits.Select(c => c.SHA).ToHashSet();
if (visible.Contains(section.NewSHA))
anchor.GraphParents.Add(section.GraphKey(section.NewSHA));
// A rewind/divergent update must also expose the previous pointer.
foreach (var old in section.Previous.Keys)
{
if (visible.Contains(old) && old != section.NewSHA)
anchor.GraphParents.Add(section.GraphKey(old));
}

foreach (var commit in section.Commits)
{
var row = Copy(commit);
row.SubmoduleHistory = section;
row.GraphParents = commit.Parents.Where(visible.Contains).Select(section.GraphKey).ToList();
row.Subject = $"[{section.Path}] {commit.Subject}";
row.Decorators = [];
row.IsMerged = true;
if (section.Previous.TryGetValue(commit.SHA, out var previousChange) &&
rootPositions.TryGetValue(previousChange, out var position) && position > rootPositions[root.SHA])
row.GraphParents.Add(previousChange);
result.Add(row);
}
}
}
return result;
}

private static Commit Copy(Commit c) => new()
{
SHA = c.SHA,
Parents = new(c.Parents),
Subject = c.Subject,
Author = c.Author,
AuthorTime = c.AuthorTime,
Committer = c.Committer,
CommitterTime = c.CommitterTime,
Decorators = new(c.Decorators),
IsMerged = c.IsMerged,
};
}
}
Loading