diff --git a/src/Commands/QuerySubmoduleHistory.cs b/src/Commands/QuerySubmoduleHistory.cs new file mode 100644 index 000000000..e56c9ff6b --- /dev/null +++ b/src/Commands/QuerySubmoduleHistory.cs @@ -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> GetChangesAsync(Models.Commit commit) + { + var current = await ReadLinksAsync(commit.SHA).ConfigureAwait(false); + var parents = new List>(); + 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(); + 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 { "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 ReadCommitAsync(string path, string sha) => + GitAsync(path, ["show", "--no-ext-diff", "--no-textconv", "--no-show-signature", "--format=fuller", "--stat", sha, "--"]); + + private async Task> ReadLinksAsync(string revision) + { + var output = await GitAsync(repository, ["ls-tree", "-r", "-z", revision]).ConfigureAwait(false); + var links = new Dictionary(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 ParseLog(string text) + { + var result = new List(); + 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 GitAsync(string path, IEnumerable 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); + } + } +} diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index de299ed6b..390f2aa7a 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -24,6 +24,11 @@ public class Commit : ObservableObject public List Parents { get; set; } = new(); public List Decorators { get; set; } = new(); + public SubmoduleHistory SubmoduleHistory { get; set; } + public string GraphKey => SubmoduleHistory?.GraphKey(SHA) ?? SHA; + public List GraphParents { get; set; } + public List DisplayParents => GraphParents ?? Parents; + public bool IsMerged { get; set; } = false; public int Color { get; set; } = 0; public double LeftMargin { get; set; } = 0; diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index 916a79b1b..7e33e8be8 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -98,7 +98,7 @@ public static CommitGraph Generate(List 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) { @@ -106,9 +106,9 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna 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 @@ -151,12 +151,12 @@ public static CommitGraph Generate(List 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 @@ -164,12 +164,12 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna { 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; } @@ -182,14 +182,14 @@ public static CommitGraph Generate(List 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); @@ -201,7 +201,7 @@ public static CommitGraph Generate(List 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; @@ -213,9 +213,9 @@ public static CommitGraph Generate(List 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) { diff --git a/src/Models/SubmoduleHistory.cs b/src/Models/SubmoduleHistory.cs new file mode 100644 index 000000000..1b05ba2b0 --- /dev/null +++ b/src/Models/SubmoduleHistory.cs @@ -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 Previous { get; } = new(); + public List 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 Compose(List roots, IReadOnlyList expanded) + { + var result = new List(); + 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, + }; + } +} diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs index 4803ec429..0fd63ead0 100644 --- a/src/ViewModels/Preferences.cs +++ b/src/ViewModels/Preferences.cs @@ -135,6 +135,18 @@ public int MaxHistoryCommits set => SetProperty(ref _maxHistoryCommits, value); } + public Models.SubmoduleHistoryMode SubmoduleHistoryMode + { + get => _submoduleHistoryMode; + set => SetProperty(ref _submoduleHistoryMode, Enum.IsDefined(value) ? value : Models.SubmoduleHistoryMode.ChangedRange); + } + + public int MaxExpandedSubmoduleHistories + { + get => _maxExpandedSubmoduleHistories; + set => SetProperty(ref _maxExpandedSubmoduleHistories, Math.Clamp(value, 1, 20)); + } + public int SubjectGuideLength { get => _subjectGuideLength; @@ -818,6 +830,8 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) private double _zoom = 1.0; private LayoutInfo _layout = new(); + private Models.SubmoduleHistoryMode _submoduleHistoryMode = Models.SubmoduleHistoryMode.ChangedRange; + private int _maxExpandedSubmoduleHistories = 3; private int _maxHistoryCommits = 20000; private int _subjectGuideLength = 50; private bool _useFixedTabWidth = true; diff --git a/tests/SourceGit.SubmoduleHistory.Tests/Program.cs b/tests/SourceGit.SubmoduleHistory.Tests/Program.cs new file mode 100644 index 000000000..eac8b2bb9 --- /dev/null +++ b/tests/SourceGit.SubmoduleHistory.Tests/Program.cs @@ -0,0 +1,131 @@ +using System.Diagnostics; +using SourceGit.Commands; +using SourceGit.Models; +using Commit = SourceGit.Models.Commit; + +var temp = Path.Combine(Path.GetTempPath(), "sourcegit-history-" + Guid.NewGuid().ToString("N")); +Directory.CreateDirectory(temp); +var checks = 0; +try +{ + var child = Init("child"); + var a = CommitFile(child, "base.txt", "base"); + var b = CommitFile(child, "next.txt", "next"); + Git(child, "checkout", "-b", "side", a); + var side = CommitFile(child, "side.txt", "side"); + Git(child, "checkout", "master"); + Git(child, "merge", "--no-ff", "side", "-m", "merge child"); + var merged = Git(child, "rev-parse", "HEAD"); + var root = Init("parent"); + CommitFile(root, "root.txt", "root"); + Git(root, "-c", "protocol.file.allow=always", "submodule", "add", child, "modules/with space"); + Git(root, "-c", "protocol.file.allow=always", "submodule", "add", child, "second"); + var module = Path.Combine(root, "modules/with space"); + Git(module, "checkout", a); + Git(Path.Combine(root, "second"), "checkout", a); + Git(root, "add", "."); + Git(root, "commit", "-m", "add modules"); + var initial = ReadCommit(root); + Git(module, "checkout", merged); + Git(Path.Combine(root, "second"), "checkout", merged); + Git(root, "add", "."); + Git(root, "commit", "-m", "update modules"); + var update = ReadCommit(root); + var query = new QuerySubmoduleHistory(root); + var sections = await query.GetChangesAsync(update); + Check(sections.Count == 2, "discover both direct gitlinks, including a path with spaces"); + Check(sections.All(x => x.Previous[a] == initial.SHA), "find previous superproject change anchors"); + foreach (var section in sections) + await query.LoadAsync(section, SubmoduleHistoryMode.ChangedRange, 100); + var first = sections[0]; + Check(first.Error.Length == 0, "load initialized submodule"); + Check(first.Commits.Select(x => x.SHA).ToHashSet().SetEquals([a, b, side, merged]), "range includes both merge parents and old boundary"); + Check(first.Commits.Single(x => x.SHA == merged).Parents.Count == 2, "retain real merge ancestry"); + var rows = SubmoduleHistory.Compose([update, initial], sections); + Check(rows.Select(x => x.GraphKey).Distinct().Count() == rows.Count, "same SHA in different modules has unique graph identity"); + Check(update.GraphParents == null && update.Parents.Count == 1, "composition does not mutate source commits"); + Check(rows.Any(x => x.SubmoduleHistory != null && x.DisplayParents.Contains(initial.SHA)), "old pointer reconnects to previous superproject change"); + CommitGraph.SetDefaultPens(); + Check(CommitGraph.Generate(rows, false, CommitGraphHighlighting.All, []).Dots.Count == rows.Count, "layout renders all root and submodule rows"); + await query.LoadAsync(first, SubmoduleHistoryMode.FullHistory, 1); + Check(first.IsTruncated && first.Commits.Count == 1, "large full history reports truncation"); + await query.LoadAsync(first, SubmoduleHistoryMode.FullHistory, 100); + Check(first.Commits.Any(x => x.SHA == a), "full history reaches root"); + Check(first.Commits[0].SHA == merged, "full history uses topological order"); + Git(module, "checkout", b); + Git(root, "add", "."); Git(root, "commit", "-m", "rewind"); + var rewind = (await query.GetChangesAsync(ReadCommit(root))).Single(); + await query.LoadAsync(rewind, SubmoduleHistoryMode.ChangedRange, 100); + Check(rewind.Commits.Any(x => x.SHA == b) && rewind.Commits.Any(x => x.SHA == merged), "rewind retains new and old pointers"); + Git(module, "checkout", side); + Git(root, "add", "."); Git(root, "commit", "-m", "diverge"); + var divergent = (await query.GetChangesAsync(ReadCommit(root))).Single(); + await query.LoadAsync(divergent, SubmoduleHistoryMode.ChangedRange, 100); + Check(divergent.Commits.Any(x => x.SHA == side) && divergent.Commits.Any(x => x.SHA == b), "divergent update exposes both tips"); + Check(!divergent.Commits.Single(x => x.SHA == side).Parents.Contains(b), "never invent ancestry between divergent pointers"); + var unchangedSHA = CommitFile(root, "root.txt", "unrelated"); + Check((await query.GetChangesAsync(ReadCommit(root))).Count == 0, "unrelated commit does not expand submodules"); + var additions = await query.GetChangesAsync(initial); + Check(additions.All(x => x.Previous.Count == 0), "submodule additions have no old boundary"); + Git(root, "rm", "-f", "second"); Git(root, "commit", "-m", "delete module"); + var deleted = (await query.GetChangesAsync(ReadCommit(root))).Single(); + Check(deleted.NewSHA == null && deleted.Previous.Count == 1, "detect removed gitlinks"); + await query.LoadAsync(deleted, SubmoduleHistoryMode.ChangedRange, 100); + Check(deleted.Error == "SubmoduleHistory.Unavailable", "deleted/uninitialized module gives actionable unavailable status"); + var missing = new SubmoduleHistory { OwnerSHA = update.SHA, Path = "modules/with space", NewSHA = new string('1', 40) }; + await query.LoadAsync(missing, SubmoduleHistoryMode.FullHistory, 100); + Check(missing.Error == "SubmoduleHistory.MissingObjects", "missing objects do not appear as empty success"); + var escape = new SubmoduleHistory { OwnerSHA = update.SHA, Path = "../child", NewSHA = a }; + await query.LoadAsync(escape, SubmoduleHistoryMode.FullHistory, 100); + Check(escape.Error.Length > 0, "reject path traversal outside parent repository"); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + try { await new QuerySubmoduleHistory(root, cancellation.Token).GetChangesAsync(update); throw new Exception("cancellation ignored"); } + catch (OperationCanceledException) { checks++; } + var preferences = new SourceGit.ViewModels.Preferences(); + Check(preferences.MaxExpandedSubmoduleHistories == 3 && preferences.SubmoduleHistoryMode == SubmoduleHistoryMode.ChangedRange, "default preferences"); + preferences.MaxExpandedSubmoduleHistories = 0; + Check(preferences.MaxExpandedSubmoduleHistories == 1, "validate lower expansion limit"); + preferences.MaxExpandedSubmoduleHistories = 999; + Check(preferences.MaxExpandedSubmoduleHistories == 20, "validate upper expansion limit"); + Console.WriteLine($"PASS: {checks} integration assertions"); +} +finally { Directory.Delete(temp, true); } + +string Init(string name) +{ + var path = Path.Combine(temp, name); + Directory.CreateDirectory(path); + Git(path, "init", "-b", "master"); + Git(path, "config", "user.name", "SourceGit tests"); + Git(path, "config", "user.email", "tests@example.invalid"); + return path; +} +string CommitFile(string path, string name, string text) +{ + File.WriteAllText(Path.Combine(path, name), text); + Git(path, "add", "."); Git(path, "commit", "-m", text); + return Git(path, "rev-parse", "HEAD"); +} +Commit ReadCommit(string path) +{ + var fields = Git(path, "log", "-1", "--format=%H %P").Split(' ', StringSplitOptions.RemoveEmptyEntries); + return new Commit { SHA = fields[0], Parents = fields.Skip(1).ToList() }; +} +void Check(bool condition, string message) +{ + if (!condition) throw new Exception(message); + checks++; + Console.WriteLine("PASS " + message); +} +string Git(string path, params string[] args) +{ + using var process = new Process { StartInfo = new ProcessStartInfo("git") { WorkingDirectory = path, RedirectStandardOutput = true, RedirectStandardError = true } }; + foreach (var arg in args) process.StartInfo.ArgumentList.Add(arg); + process.Start(); + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + if (process.ExitCode != 0) throw new Exception(error.Result); + return output.Result.Trim(); +} diff --git a/tests/SourceGit.SubmoduleHistory.Tests/SourceGit.SubmoduleHistory.Tests.csproj b/tests/SourceGit.SubmoduleHistory.Tests/SourceGit.SubmoduleHistory.Tests.csproj new file mode 100644 index 000000000..fd214d7b7 --- /dev/null +++ b/tests/SourceGit.SubmoduleHistory.Tests/SourceGit.SubmoduleHistory.Tests.csproj @@ -0,0 +1,10 @@ + + + Exe + net10.0 + enable + + + + +