From b8d565e20895cfd4cf850189c9f5d87e1576a1ff Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Fri, 30 Oct 2026 12:54:55 +0100 Subject: [PATCH 1/2] fix: distinguish stacked branches in commit graph colors --- src/Models/CommitGraph.cs | 51 +++++- .../CommitGraphChecks.csproj | 10 ++ tests/CommitGraphChecks/Program.cs | 162 ++++++++++++++++++ tests/CommitGraphChecks/README.md | 13 ++ 4 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 tests/CommitGraphChecks/CommitGraphChecks.csproj create mode 100644 tests/CommitGraphChecks/Program.cs create mode 100644 tests/CommitGraphChecks/README.md diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index 916a79b1bb..63e1f4af8b 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -195,6 +195,26 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna temp.Paths.Add(major.Path); } + var hasBranch = commit.Decorators.Exists(x => x.Type is DecoratorType.CurrentBranchHead or DecoratorType.LocalBranchHead or DecoratorType.RemoteBranchHead); + if (major != null && hasBranch) + { + if (major.HasBranch) + { + // A named branch can start on an existing line without a fork. + // Split its color at the branch tip, preserving the lane and highlighting. + var oldColor = major.Path.Color; + var color = colorPicker.Next(oldColor); + if (color != oldColor) + { + major.ChangeColor(color); + if (commit.Parents.Count > 0) + temp.Paths.Add(major.Path); + } + colorPicker.Recycle(commit.Parents.Count > 0 ? oldColor : color); + } + major.HasBranch = true; + } + // Calculate link position of this commit. var position = new Point(major?.LastX ?? offsetX, offsetY); var dotColor = major?.Path.Color ?? 0; @@ -270,15 +290,22 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna private class ColorPicker { - public int Next() + public int Next(int exclude = -1) { - if (_colorsQueue.Count == 0) + int color; + do { - for (var i = 0; i < s_penCount; i++) - _colorsQueue.Enqueue(i); + if (_colorsQueue.Count == 0) + { + for (var i = 0; i < s_penCount; i++) + _colorsQueue.Enqueue(i); + } + + color = _colorsQueue.Dequeue(); } + while (s_penCount > 1 && color == exclude); - return _colorsQueue.Dequeue(); + return color; } public void Recycle(int idx) @@ -292,6 +319,7 @@ public void Recycle(int idx) private class PathHelper { + public bool HasBranch { get; set; } public Path Path { get; private set; } public string Next { get; set; } public double LastX { get; private set; } @@ -398,10 +426,19 @@ public void End(double x, double y, double halfHeight) /// public void Highlight() { - var color = Path.Color; + Restart(Path.Color, true); + } + + public void ChangeColor(int color) + { + Restart(color, Path.IsHighlighted); + } + + private void Restart(int color, bool isHighlighted) + { Add(LastX, _lastY); - Path = new Path(color, true); + Path = new Path(color, isHighlighted); Path.Points.Add(new Point(LastX, _lastY)); _endY = 0; } diff --git a/tests/CommitGraphChecks/CommitGraphChecks.csproj b/tests/CommitGraphChecks/CommitGraphChecks.csproj new file mode 100644 index 0000000000..fd214d7b72 --- /dev/null +++ b/tests/CommitGraphChecks/CommitGraphChecks.csproj @@ -0,0 +1,10 @@ + + + Exe + net10.0 + enable + + + + + diff --git a/tests/CommitGraphChecks/Program.cs b/tests/CommitGraphChecks/Program.cs new file mode 100644 index 0000000000..f80a6d37cc --- /dev/null +++ b/tests/CommitGraphChecks/Program.cs @@ -0,0 +1,162 @@ +using SourceGit.Models; + +internal static class Program +{ + private static int _checks; + + private static void Main() + { + CommitGraph.SetDefaultPens(); + CheckStackedBranches(); + CheckSharedBase(); + CheckMergeLane(); + CheckHighlighting(); + CheckSmallPalettes(); + Console.WriteLine($"PASS: {_checks} commit graph checks"); + } + + private static void CheckStackedBranches() + { + var root = new Commit { SHA = "root" }; + var main = Commit("main", root, "refs/heads/main, refs/remotes/origin/main"); + var first = Commit("first", main, "tag: refs/tags/v1"); + var feature = Commit("feature", first, "refs/heads/feature"); + var second = Commit("second", feature); + var tip = Commit("tip", second, "HEAD -> refs/heads/topic, refs/remotes/origin/topic, refs/heads/alias"); + List commits = [tip, second, feature, first, main, root]; + var graph = CommitGraph.Generate(commits, false, CommitGraphHighlighting.All, []); + Check(tip.Color == second.Color && feature.Color == first.Color && main.Color == root.Color, + "commits and tags on each branch segment keep the same color"); + Check(tip.Color != feature.Color && feature.Color != main.Color, + "each stacked branch changes color at its base branch tip"); + Check(graph.Dots.All(x => x.Center.X == 10) && commits.All(x => x.LeftMargin == 18), + "stacked branches keep one lane and the original graph width"); + Check(graph.Paths.Count == 3 && graph.Dots.Count == commits.Count, + "multiple branch names at one tip do not add paths or dots"); + for (var i = 0; i < 4; i += 2) + { + var boundary = graph.Dots[i + 2].Center; + Check(graph.Paths.Any(x => x.Color == commits[i].Color && x.Points[^1] == boundary) && + graph.Paths.Any(x => x.Color == commits[i + 2].Color && x.Points[0] == boundary), + "color segments meet exactly at the named branch dot"); + } + + root.ParseDecorators("refs/heads/root"); + graph = CommitGraph.Generate(commits, false, CommitGraphHighlighting.All, []); + Check(root.Color != main.Color && graph.Paths.Count == 3 && graph.Paths[^1].Points[^1] == graph.Dots[^1].Center, + "a branch at the root gets a distinct dot color without an extra edge"); + + var aliasRoot = new Commit { SHA = "alias-root" }; + var aliases = Commit("aliases", aliasRoot, "HEAD -> refs/heads/main, refs/heads/other, refs/remotes/origin/main"); + graph = CommitGraph.Generate([aliases, aliasRoot], false, CommitGraphHighlighting.All, []); + Check(graph.Paths.Count == 1 && graph.Dots.Count == 2 && aliases.Color == aliasRoot.Color, + "branches pointing at the same commit share the existing dot and color"); + } + + private static void CheckSharedBase() + { + var root = new Commit { SHA = "root" }; + var shared = Commit("shared", root, "refs/heads/main"); + var left = Commit("left", shared, "refs/heads/left"); + var right = Commit("right", shared, "refs/heads/right"); + var third = Commit("third", shared, "refs/heads/third"); + var graph = CommitGraph.Generate([left, right, third, shared, root], false, CommitGraphHighlighting.All, []); + Check(new[] { left.Color, right.Color, third.Color, shared.Color }.Distinct().Count() == 4, + "several branches from one named base retain distinct colors"); + Check(graph.Dots.Take(3).Select(x => x.Center.X).Distinct().Count() == 3 && graph.Dots.Count == 5, + "diverging branches keep separate lanes and one shared base commit"); + Check(shared.Color == root.Color, "the shared ancestry uses the base branch color"); + } + + private static void CheckMergeLane() + { + foreach (var firstParentOnly in new[] { false, true }) + { + var root = new Commit { SHA = "root" }; + var mainParent = Commit("main-parent", root); + var featureParent = Commit("feature-parent", root); + var feature = Commit("feature", featureParent, "refs/heads/feature"); + var merge = Commit("merge", mainParent, "HEAD -> refs/heads/main"); + merge.Parents.Add(feature.SHA); + var graph = CommitGraph.Generate([merge, feature, mainParent, featureParent, root], firstParentOnly, CommitGraphHighlighting.All, []); + Check(merge.Color == mainParent.Color && feature.Color == featureParent.Color && merge.Color != feature.Color, + "a merged branch keeps the color already assigned to its separate lane"); + Check(graph.Paths.Count == 2, "a branch on an existing merge lane does not add a redundant color split"); + } + + var detachedRoot = new Commit { SHA = "detached-root" }; + var detached = Commit("detached", detachedRoot, "HEAD, tag: refs/tags/v1"); + var detachedGraph = CommitGraph.Generate([detached, detachedRoot], false, CommitGraphHighlighting.All, []); + Check(detachedGraph.Paths.Count == 1 && detached.Color == detachedRoot.Color, + "detached HEAD and tags preserve the existing lane color"); + } + + private static void CheckHighlighting() + { + foreach (var highlighting in Enum.GetValues()) + foreach (var firstParentOnly in new[] { false, true }) + foreach (var selectBase in new[] { false, true }) + { + var root = new Commit { SHA = "root", IsMerged = true }; + var branchBase = Commit("base", root); + var feature = Commit("feature", branchBase, "refs/heads/feature"); + var main = Commit("main", root, "HEAD -> refs/heads/main"); + List commits = [feature, branchBase, main, root]; + var selected = selectBase ? branchBase.SHA : main.SHA; + var original = CommitGraph.Generate(commits, firstParentOnly, highlighting, [selected]); + var highlights = commits.Select(x => x.IsHighlightedInGraph).ToArray(); + branchBase.ParseDecorators("refs/heads/base"); + var colored = CommitGraph.Generate(commits, firstParentOnly, highlighting, [selected]); + Check(original.Dots.Select(x => x.IsHighlighted).SequenceEqual(colored.Dots.Select(x => x.IsHighlighted)) && + commits.Select(x => x.IsHighlightedInGraph).SequenceEqual(highlights), + "branch colors preserve highlighted and grayed-out commits in every mode"); + Check(colored.Paths.Any(x => x.Color == branchBase.Color && x.Points[0] == colored.Dots[1].Center && + x.IsHighlighted == branchBase.IsHighlightedInGraph), + "the new branch segment preserves its highlighted or grayed-out state"); + Check(original.Dots.Select(x => x.Center).SequenceEqual(colored.Dots.Select(x => x.Center)), + "recoloring preserves the layout in every highlighting mode"); + if (highlighting == CommitGraphHighlighting.CurrentBranchOnly) + Check(!colored.Dots[0].IsHighlighted && !colored.Dots[1].IsHighlighted && colored.Dots[2].IsHighlighted, + "inactive stacked branches stay gray while the current branch stays highlighted"); + } + } + + private static void CheckSmallPalettes() + { + var colors = CommitGraph.Pens.Take(2).Select(x => ((Avalonia.Media.ISolidColorBrush)x.Brush).Color).ToList(); + try + { + foreach (var colorCount in new[] { 1, 2 }) + { + CommitGraph.SetPens(colors.Take(colorCount).ToList(), 2); + var stack = Enumerable.Range(0, 12).Select(i => new Commit { SHA = $"stack-{i}", Parents = [$"stack-{i + 1}"] }).ToList(); + for (var i = 0; i < stack.Count; i++) + stack[i].ParseDecorators($"refs/heads/branch-{i}"); + var graph = CommitGraph.Generate(stack, false, CommitGraphHighlighting.All, []); + Check(stack.All(x => x.Color >= 0 && x.Color < colorCount), "small palettes always produce valid colors"); + Check(colorCount == 1 || stack.Zip(stack.Skip(1)).All(x => x.First.Color != x.Second.Color), + "adjacent stacked branches stay distinct when the palette is recycled"); + Check(graph.Paths.Count == (colorCount == 1 ? 1 : stack.Count), + "a single-color palette does not create redundant path segments"); + } + } + finally + { + CommitGraph.SetDefaultPens(); + } + } + + private static Commit Commit(string sha, Commit parent, string decorators = "") + { + var commit = new Commit { SHA = sha, Parents = [parent.SHA] }; + commit.ParseDecorators(decorators); + return commit; + } + + private static void Check(bool condition, string name) + { + if (!condition) + throw new Exception("FAIL: " + name); + _checks++; + } +} diff --git a/tests/CommitGraphChecks/README.md b/tests/CommitGraphChecks/README.md new file mode 100644 index 0000000000..004c177936 --- /dev/null +++ b/tests/CommitGraphChecks/README.md @@ -0,0 +1,13 @@ +# Commit graph regression checks + +Run with .NET 10 from the repository root: + +```sh +dotnet run --project tests/CommitGraphChecks -p:UsedAvaloniaProducts= +``` + +The standalone executable checks stacked branch color boundaries, shared branch +bases, multiple names at one commit, tags, root commits, merged branch lanes, +small palettes, and every graph highlighting mode. It verifies that recoloring +preserves lane positions and grayed-out sections without opening a window or +changing Git history or user preferences. From a40f8b32f48de1aaa97700f0586177445e087c0b Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Fri, 30 Oct 2026 16:59:19 +0100 Subject: [PATCH 2/2] feat: match current branch color to repository bookmark --- src/Models/Commit.cs | 7 +- src/Models/CommitGraph.cs | 44 +++- src/ViewModels/Histories.cs | 7 +- src/ViewModels/Repository.cs | 2 + src/ViewModels/RepositoryNode.cs | 12 +- src/Views/CommitRefsPresenter.cs | 12 +- src/Views/Histories.axaml | 1 + .../BookmarkBranchColorChecks.csproj | 10 + tests/BookmarkBranchColorChecks/Program.cs | 232 ++++++++++++++++++ tests/BookmarkBranchColorChecks/README.md | 13 + 10 files changed, 328 insertions(+), 12 deletions(-) create mode 100644 tests/BookmarkBranchColorChecks/BookmarkBranchColorChecks.csproj create mode 100644 tests/BookmarkBranchColorChecks/Program.cs create mode 100644 tests/BookmarkBranchColorChecks/README.md diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index de299ed6b7..ef6d4a6df2 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -25,7 +25,11 @@ public class Commit : ObservableObject public List Decorators { get; set; } = new(); public bool IsMerged { get; set; } = false; - public int Color { get; set; } = 0; + public int Color + { + get => _color; + set => SetProperty(ref _color, value); + } public double LeftMargin { get; set; } = 0; public bool IsHighlightedInGraph @@ -125,6 +129,7 @@ public void ParseDecorators(string data) }); } + private int _color = 0; private bool _isHighlightedInGraph = false; } diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index 63e1f4af8b..ccd76e90bf 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -34,6 +34,8 @@ public static void SetPens(List colors, double thickness) Pens.Add(new Pen(c.ToUInt32(), thickness)); s_penCount = colors.Count; + for (var i = 1; i < Bookmarks.Brushes.Length; i++) + Pens.Add(new Pen(Bookmarks.Brushes[i], thickness)); } public class Path(int color, bool isHighlighted) @@ -71,7 +73,7 @@ public class Dot public List Links { get; } = []; public List Dots { get; } = []; - public static CommitGraph Generate(List commits, bool firstParentOnlyEnabled, CommitGraphHighlighting highlighting, HashSet highlightExtraCommits) + public static CommitGraph Generate(List commits, bool firstParentOnlyEnabled, CommitGraphHighlighting highlighting, HashSet highlightExtraCommits, int bookmark = 0) { const double unitWidth = 12; const double halfWidth = 6; @@ -82,12 +84,14 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna var unsolved = new List(); var ended = new List(); var offsetY = -halfHeight; - var colorPicker = new ColorPicker(); + var headColor = commits.Exists(x => x.IsCurrentHead) ? GetBookmarkColor(bookmark) : -1; + var colorPicker = new ColorPicker(headColor); var defHighlighting = highlighting == CommitGraphHighlighting.All; foreach (var commit in commits) { PathHelper major = null; + var isBookmarkedHead = commit.IsCurrentHead && headColor >= 0; // Update current y offset offsetY += unitHeight; @@ -184,7 +188,8 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna if (commit.Parents.Count > 0) { - major = new PathHelper(commit.Parents[0], isHighlighted, colorPicker.Next(), new Point(offsetX, offsetY)); + var color = isBookmarkedHead ? headColor : colorPicker.Next(); + major = new PathHelper(commit.Parents[0], isHighlighted, color, new Point(offsetX, offsetY)); unsolved.Add(major); temp.Paths.Add(major.Path); } @@ -198,7 +203,7 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna var hasBranch = commit.Decorators.Exists(x => x.Type is DecoratorType.CurrentBranchHead or DecoratorType.LocalBranchHead or DecoratorType.RemoteBranchHead); if (major != null && hasBranch) { - if (major.HasBranch) + if (major.HasBranch && !isBookmarkedHead) { // A named branch can start on an existing line without a fork. // Split its color at the branch tip, preserving the lane and highlighting. @@ -215,9 +220,17 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna major.HasBranch = true; } + if (isBookmarkedHead && major != null && major.Path.Color != headColor) + { + colorPicker.Recycle(major.Path.Color); + major.ChangeColor(headColor); + if (commit.Parents.Count > 0) + temp.Paths.Add(major.Path); + } + // Calculate link position of this commit. var position = new Point(major?.LastX ?? offsetX, offsetY); - var dotColor = major?.Path.Color ?? 0; + var dotColor = major?.Path.Color ?? (isBookmarkedHead ? headColor : 0); var anchor = new Dot() { Center = position, Color = dotColor, IsHighlighted = isHighlighted }; if (commit.IsCurrentHead) anchor.Type = DotType.Head; @@ -288,10 +301,25 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna return temp; } - private class ColorPicker + private static int GetBookmarkColor(int bookmark) + { + if (Bookmarks.Get(bookmark) is not ISolidColorBrush brush) + return -1; + + for (var i = 0; i < s_penCount; i++) + { + if (Pens[i].Brush is ISolidColorBrush graphBrush && graphBrush.Color == brush.Color) + return i; + } + + return s_penCount + bookmark - 1; + } + + private class ColorPicker(int reservedColor) { public int Next(int exclude = -1) { + var reservedCount = s_penCount > 1 && reservedColor >= 0 && reservedColor < s_penCount ? 1 : 0; int color; do { @@ -303,14 +331,14 @@ public int Next(int exclude = -1) color = _colorsQueue.Dequeue(); } - while (s_penCount > 1 && color == exclude); + while ((reservedCount > 0 && color == reservedColor) || (s_penCount - reservedCount > 1 && color == exclude)); return color; } public void Recycle(int idx) { - if (!_colorsQueue.Contains(idx)) + if (idx < s_penCount && idx != reservedColor && !_colorsQueue.Contains(idx)) _colorsQueue.Enqueue(idx); } diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index a22f810cdb..1101f43641 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -567,6 +567,11 @@ private void PostSelectedCommitsChanged() GenerateGraph(_commits); } + public void RefreshGraph() + { + GenerateGraph(_commits); + } + private void GenerateGraph(List commits) { var firstParentOnly = _repo.UIStates.HistoryShowFlags.HasFlag(Models.HistoryShowFlags.FirstParentOnly); @@ -579,7 +584,7 @@ private void GenerateGraph(List commits) extraHeads.Add(c.SHA); } - Graph = Models.CommitGraph.Generate(commits, firstParentOnly, highlighting, extraHeads); + Graph = Models.CommitGraph.Generate(commits, firstParentOnly, highlighting, extraHeads, _repo.Bookmark); } private Repository _repo = null; diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index e303facd5b..f617006f35 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -29,6 +29,8 @@ public string GitDir get; } + public int Bookmark => GetOwnerPage()?.Node.Bookmark ?? Preferences.Instance.FindNode(FullPath)?.Bookmark ?? 0; + public Models.RepositorySettings Settings { get => _settings; diff --git a/src/ViewModels/RepositoryNode.cs b/src/ViewModels/RepositoryNode.cs index 9d7ab9debc..dc6313b9f1 100644 --- a/src/ViewModels/RepositoryNode.cs +++ b/src/ViewModels/RepositoryNode.cs @@ -36,7 +36,17 @@ public string Name public int Bookmark { get => _bookmark; - set => SetProperty(ref _bookmark, value); + set + { + if (!SetProperty(ref _bookmark, value) || App.GetLauncher() is not { } launcher) + return; + + foreach (var page in launcher.Pages) + { + if (ReferenceEquals(page.Node, this) && page.Data is Repository repo) + repo.Histories?.RefreshGraph(); + } + } } public bool IsRepository diff --git a/src/Views/CommitRefsPresenter.cs b/src/Views/CommitRefsPresenter.cs index 85e33962a1..4da7dc06a0 100644 --- a/src/Views/CommitRefsPresenter.cs +++ b/src/Views/CommitRefsPresenter.cs @@ -139,6 +139,15 @@ public bool UseGraphColor set => SetValue(UseGraphColorProperty, value); } + public static readonly StyledProperty GraphColorProperty = + AvaloniaProperty.Register(nameof(GraphColor), -1); + + public int GraphColor + { + get => GetValue(GraphColorProperty); + set => SetValue(GraphColorProperty, value); + } + public static readonly StyledProperty AllowWrapProperty = AvaloniaProperty.Register(nameof(AllowWrap)); @@ -260,6 +269,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang change.Property == FontSizeProperty || change.Property == ForegroundProperty || change.Property == UseGraphColorProperty || + change.Property == GraphColorProperty || change.Property == UseCompactBranchNamesProperty || change.Property == HasSingleRemoteProperty || change.Property == BackgroundProperty || @@ -294,7 +304,7 @@ protected override Size MeasureOverride(Size availableSize) var typefaceHead = new Typeface(FontFamily, FontStyle.Normal, FontWeight.Bold); var typefaceRemote = new Typeface(FontFamily, FontStyle.Italic, FontWeight.Bold); var fg = Foreground; - var normalBG = UseGraphColor ? Models.CommitGraph.Pens[commit.Color].Brush : Brushes.Gray; + var normalBG = UseGraphColor ? Models.CommitGraph.Pens[GraphColor >= 0 ? GraphColor : commit.Color].Brush : Brushes.Gray; var labelSize = FontSize; var requiredHeight = 16.0; var x = 0.0; diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index a3d24529fd..f1b1bd6d3c 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -133,6 +133,7 @@ FontSize="12" VerticalAlignment="Center" UseGraphColor="{Binding IsHighlightedInGraph, Mode=OneWay}" + GraphColor="{Binding Color, Mode=OneWay}" UseCompactBranchNames="{Binding Source={x:Static vm:Preferences.Instance}, Path=UseCompactBranchNamesInGraph}" HasSingleRemote="{Binding $parent[v:Histories].HasSingleRemote}"/> diff --git a/tests/BookmarkBranchColorChecks/BookmarkBranchColorChecks.csproj b/tests/BookmarkBranchColorChecks/BookmarkBranchColorChecks.csproj new file mode 100644 index 0000000000..fd214d7b72 --- /dev/null +++ b/tests/BookmarkBranchColorChecks/BookmarkBranchColorChecks.csproj @@ -0,0 +1,10 @@ + + + Exe + net10.0 + enable + + + + + diff --git a/tests/BookmarkBranchColorChecks/Program.cs b/tests/BookmarkBranchColorChecks/Program.cs new file mode 100644 index 0000000000..5b9e7c2ad5 --- /dev/null +++ b/tests/BookmarkBranchColorChecks/Program.cs @@ -0,0 +1,232 @@ +using System.Reflection; +using Avalonia; +using Avalonia.Media; +using Avalonia.Data; +using SourceGit; +using SourceGit.Models; +using SourceGit.ViewModels; +using Commit = SourceGit.Models.Commit; + +internal static class Program +{ + private static int _checks; + + [STAThread] + private static void Main() + { + CommitGraph.SetDefaultPens(); + CheckColorsAndHighlighting(); + CheckOlderHead(); + CheckFallbacks(); + CheckCustomPalettes(); + CheckStackedBookmarkColors(); + CheckLiveBookmarks(); + Console.WriteLine($"PASS: {_checks} bookmark branch color checks"); + } + + private static void CheckColorsAndHighlighting() + { + for (var bookmark = 1; bookmark < Bookmarks.Brushes.Length; bookmark++) + foreach (var highlighting in Enum.GetValues()) + foreach (var firstParentOnly in new[] { false, true }) + { + var root = new Commit { SHA = "root", IsMerged = true }; + var mainParent = Commit("main-parent", root); + mainParent.IsMerged = true; + var featureParent = Commit("feature-parent", root); + var feature = Commit("feature", featureParent, "refs/heads/feature"); + var head = Commit("head", mainParent, "HEAD -> refs/heads/main"); + head.Parents.Add(feature.SHA); + List commits = [head, feature, mainParent, featureParent, root]; + var baseline = CommitGraph.Generate(commits, firstParentOnly, highlighting, [feature.SHA]); + var margins = commits.Select(x => x.LeftMargin).ToArray(); + var graph = CommitGraph.Generate(commits, firstParentOnly, highlighting, [feature.SHA], bookmark); + Check(BrushColor(head.Color) == ((ISolidColorBrush)Bookmarks.Get(bookmark)).Color, + "the current branch uses the exact repository bookmark color"); + Check(mainParent.Color == head.Color && root.Color == head.Color && feature.Color != head.Color, + "the first-parent branch keeps its bookmark color while the merged branch stays distinct"); + Check(baseline.Dots.Select(x => x.IsHighlighted).SequenceEqual(graph.Dots.Select(x => x.IsHighlighted)) && + baseline.Paths.Select(x => x.IsHighlighted).SequenceEqual(graph.Paths.Select(x => x.IsHighlighted)), + "bookmark colors preserve highlighted and grayed-out sections in every mode"); + Check(baseline.Dots.Select(x => x.Center).SequenceEqual(graph.Dots.Select(x => x.Center)) && + commits.Select(x => x.LeftMargin).SequenceEqual(margins), + "bookmark colors preserve lane positions and graph width"); + } + } + + private static void CheckOlderHead() + { + for (var bookmark = 1; bookmark < Bookmarks.Brushes.Length; bookmark++) + { + var root = new Commit { SHA = "root" }; + var head = Commit("head", root, "HEAD -> refs/heads/main"); + var tip = Commit("tip", head, "refs/heads/feature"); + var graph = CommitGraph.Generate([tip, head, root], false, CommitGraphHighlighting.All, [], bookmark); + Check(BrushColor(head.Color) == ((ISolidColorBrush)Bookmarks.Get(bookmark)).Color && tip.Color != head.Color, + "an older HEAD switches to the bookmark color without recoloring the branch above it"); + Check(graph.Paths.Count == 2 && graph.Paths[0].Points[^1] == graph.Dots[1].Center && + graph.Paths[1].Points[0] == graph.Dots[1].Center && graph.Dots.All(x => x.Center.X == 10), + "the color boundary meets the older HEAD dot without moving the lane"); + + root.Decorators = [new Decorator { Type = DecoratorType.CurrentBranchHead, Name = "main" }]; + head.Decorators.Clear(); + graph = CommitGraph.Generate([tip, head, root], false, CommitGraphHighlighting.All, [], bookmark); + Check(BrushColor(root.Color) == ((ISolidColorBrush)Bookmarks.Get(bookmark)).Color && graph.Paths.Count == 1, + "a root HEAD gets the bookmark color without adding an empty path"); + + var detached = new Commit { SHA = "detached" }; + detached.ParseDecorators("HEAD"); + graph = CommitGraph.Generate([detached], false, CommitGraphHighlighting.All, [], bookmark); + Check(graph.Dots.Count == 1 && graph.Paths.Count == 0 && BrushColor(detached.Color) == ((ISolidColorBrush)Bookmarks.Get(bookmark)).Color, + "a detached or single-commit HEAD uses the bookmark color"); + } + } + + private static void CheckFallbacks() + { + var root = new Commit { SHA = "root" }; + var head = Commit("head", root, "HEAD -> refs/heads/main"); + var baseline = CommitGraph.Generate([head, root], false, CommitGraphHighlighting.All, []); + foreach (var bookmark in new[] { 0, -1, Bookmarks.Brushes.Length, int.MaxValue }) + { + var graph = CommitGraph.Generate([head, root], false, CommitGraphHighlighting.All, [], bookmark); + Check(graph.Dots.Select(x => x.Color).SequenceEqual(baseline.Dots.Select(x => x.Color)), + "an unset or invalid bookmark retains the original graph colors"); + } + head.Decorators.Clear(); + var noHead = CommitGraph.Generate([head, root], false, CommitGraphHighlighting.All, [], 2); + Check(noHead.Dots.Select(x => x.Color).SequenceEqual(baseline.Dots.Select(x => x.Color)), + "a history without HEAD retains the original graph colors"); + Check(CommitGraph.Generate([], false, CommitGraphHighlighting.All, [], 2).Dots.Count == 0, + "an empty bookmarked history stays empty"); + + head.ParseDecorators("HEAD -> refs/heads/main"); + var first = CommitGraph.Generate([head, root], false, CommitGraphHighlighting.All, [], 7); + var firstColor = BrushColor(first.Dots[0].Color); + CommitGraph.Generate([head, root], false, CommitGraphHighlighting.All, [], 1); + Check(BrushColor(first.Dots[0].Color) == firstColor, + "generating another repository's graph does not recolor an existing graph"); + } + + private static void CheckCustomPalettes() + { + try + { + foreach (var palette in new List[] { [Colors.AliceBlue], [Colors.Red], [Colors.Gold, Colors.Purple] }) + { + CommitGraph.SetPens(palette, 3.5); + var root = new Commit { SHA = "root" }; + var head = Commit("head", root, "HEAD -> refs/heads/main"); + var tip = Commit("tip", head, "refs/heads/feature"); + for (var bookmark = 1; bookmark < Bookmarks.Brushes.Length; bookmark++) + { + var graph = CommitGraph.Generate([tip, head, root], false, CommitGraphHighlighting.All, [], bookmark); + Check(BrushColor(head.Color) == ((ISolidColorBrush)Bookmarks.Get(bookmark)).Color && + graph.Dots.All(x => x.Color >= 0 && x.Color < CommitGraph.Pens.Count), + "custom and single-color palettes support every bookmark without invalid colors"); + Check(CommitGraph.Pens[head.Color].Thickness == 3.5, "bookmark lines retain the configured graph thickness"); + } + } + } + finally + { + CommitGraph.SetDefaultPens(); + } + } + + private static void CheckStackedBookmarkColors() + { + try + { + foreach (var colors in new List[] { [Colors.Red, Colors.Orange], [Colors.Red, Colors.Orange, Colors.Gold] }) + { + CommitGraph.SetPens(colors, 2); + var root = new Commit { SHA = "root" }; + var head = Commit("head", root, "HEAD -> refs/heads/main"); + var branchBase = Commit("base", root, "refs/heads/base"); + var feature = Commit("feature", branchBase, "refs/heads/feature"); + var graph = CommitGraph.Generate([feature, branchBase, head, root], false, CommitGraphHighlighting.All, [], 1); + Check(BrushColor(head.Color) == Colors.Red && feature.Color != head.Color && branchBase.Color != head.Color, + "stacked side branches preserve the color reserved for the current branch"); + Check(colors.Count == 2 || feature.Color != branchBase.Color, + "stacked side branches remain distinct when the palette has another available color"); + Check(graph.Dots.All(x => x.Color >= 0 && x.Color < CommitGraph.Pens.Count), + "small palettes terminate with valid colors when a bookmark color is reserved"); + } + } + finally + { + CommitGraph.SetDefaultPens(); + } + } + + private static void CheckLiveBookmarks() + { + var fixture = Path.Combine(Path.GetTempPath(), "sourcegit-bookmark-colors-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(fixture); + SourceGit.Native.OS.BasicDirectories.ConfigDir = fixture; + SourceGit.Native.OS.BasicDirectories.CacheDir = fixture; + try + { + App.BuildAvaloniaApp().SetupWithoutStarting(); + var launcher = new Launcher(null); + typeof(App).GetField("_launcher", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(Application.Current, launcher); + var node = new RepositoryNode { Id = fixture, Name = "Fixture", IsRepository = true, Bookmark = 6 }; + Preferences.Instance.RepositoryNodes.Add(node); + var repo = new Repository(false, fixture, Path.Combine(fixture, ".git")); + Check(repo.Bookmark == node.Bookmark, "repositories read the saved bookmark before their tab is attached"); + var histories = new Histories(repo); + typeof(Repository).GetField("_histories", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(repo, histories); + launcher.Pages.Add(new LauncherPage(node, repo)); + var root = new Commit { SHA = "root" }; + var head = Commit("head", root, "HEAD -> refs/heads/main"); + histories.Commits = [head, root]; + var label = new SourceGit.Views.CommitRefsPresenter { DataContext = head, UseGraphColor = true, FontSize = 12 }; + label.Bind(SourceGit.Views.CommitRefsPresenter.GraphColorProperty, new Binding(nameof(SourceGit.Models.Commit.Color))); + label.Measure(new Size(200, 26)); + Check(BrushColor(head.Color) == ((ISolidColorBrush)Bookmarks.Get(6)).Color, + "the history view uses its repository tab's bookmark color"); + var original = histories.Graph; + node.Bookmark = 7; + Check(!ReferenceEquals(original, histories.Graph) && BrushColor(head.Color) == ((ISolidColorBrush)Bookmarks.Get(7)).Color, + "editing a repository bookmark immediately regenerates its open graph"); + Check(!label.IsMeasureValid && label.GraphColor == head.Color, + "editing a bookmark immediately invalidates the visible branch badge color"); + label.Measure(new Size(200, 26)); + var items = (List)typeof(SourceGit.Views.CommitRefsPresenter) + .GetField("_items", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(label); + Check(((ISolidColorBrush)items[0].Brush).Color == ((ISolidColorBrush)Bookmarks.Get(7)).Color, + "the refreshed branch badge uses the new bookmark color"); + node.Bookmark = 0; + Check(head.Color == 0, "clearing the bookmark restores the default graph color"); + node.IsUnmanaged = true; + Preferences.Instance.RepositoryNodes.Remove(node); + node.Bookmark = 3; + Check(repo.Bookmark == 3 && BrushColor(head.Color) == ((ISolidColorBrush)Bookmarks.Get(3)).Color, + "unmanaged tabs use their own bookmark even without a saved repository node"); + } + finally + { + var root = Path.GetFullPath(Path.GetTempPath()); + if (!Path.GetFullPath(fixture).StartsWith(root, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Fixture must stay inside the temporary directory"); + Directory.Delete(fixture, true); + } + } + + private static Color BrushColor(int color) => ((ISolidColorBrush)CommitGraph.Pens[color].Brush).Color; + + private static Commit Commit(string sha, Commit parent, string decorators = "") + { + var commit = new Commit { SHA = sha, Parents = [parent.SHA] }; + commit.ParseDecorators(decorators); + return commit; + } + + private static void Check(bool condition, string message) + { + if (!condition) + throw new Exception("FAIL: " + message); + _checks++; + } +} diff --git a/tests/BookmarkBranchColorChecks/README.md b/tests/BookmarkBranchColorChecks/README.md new file mode 100644 index 0000000000..b6d86992f0 --- /dev/null +++ b/tests/BookmarkBranchColorChecks/README.md @@ -0,0 +1,13 @@ +# Repository bookmark branch color checks + +Run on Windows with .NET 10: + +```sh +dotnet run --project tests/BookmarkBranchColorChecks -p:UsedAvaloniaProducts= +``` + +These standalone checks cover every bookmark color and graph highlighting mode, +an older or detached HEAD, root commits, merged branches, uncolored bookmarks, +custom graph palettes and line thickness, multiple repository colors, and live +bookmark changes for managed and unmanaged tabs. They use isolated preferences +and leave the user's repositories and settings untouched.