Skip to content

Commit e9b665c

Browse files
geevensinghCopilot
andcommitted
Add ref picker and Branch-vs-merge-base mode to New Diff dialog
Issue #4: each commit-ish input in the New Diff dialog now has a "Pick..." button that opens a popup listing the repo's local branches, remote-tracking branches, tags, and recently-used refs (derived from IRecentContextsService - no new persistence). A live filter narrows every group; an inline merge-base composer covers ad-hoc "what did this branch add" queries without leaving the dialog. Freeform typing still works as a fallback. Also adds a fifth top-level mode "Branch vs merge-base" so the dominant PR-style review workflow gets one-click setup in the left rail (without having to compose a merge-base by hand inside the picker). Resolves the two refs against the repo, computes their merge-base, and launches with merge-base on the left / branch tip on the right so additions land in the right pane - same convention every other form follows. Architecture: - New IGitRefEnumerator service (stateless libgit2 probe, sibling of IRepoInspector). Production impl opens Repository per-call, returns Empty / null on throw, excludes synthetic origin/HEAD symlinks. - New RefPickerViewModel - enumeration runs on Task.Run per AGENTS.md to keep the UI thread responsive; case-insensitive filter applied at render time; recent refs deduped + capped at 5 MRU-ordered. - New RefPicker WPF UserControl - hosts a Popup (not a Window, so not a nested dialog) with IsOpen/PlacementTarget as DependencyProperties for clean XAML binding to a ToggleButton trigger. - IDiffModeProvider.CreateForm now takes a FormDependencies record bundle (Validator, RefEnumerator, RecentContexts, PrefilledRepoPath) rather than positional args, so future form deps don't keep changing the signature. - DiffModeRegistry.BuildDefault gains BranchVsMergeBaseProvider in 4th position (local family stays grouped, GitHub PR mode trails). 998 tests pass, 0 warnings. AI-Local-Session: 324bab9a-4598-4558-bfa8-945af6bc48db AI-Cloud-Session: 620cead1-bd37-4511-a627-8793b66717fd Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cce0fec commit e9b665c

25 files changed

Lines changed: 2224 additions & 63 deletions

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,30 @@ body. Keep section headings exact and write notes in Markdown.
1414

1515
### Added
1616

17+
- **New Diff dialog: ref picker for commit-ish inputs.** The
18+
"Working tree vs commit" and "Commit vs commit" forms now have a
19+
**Pick…** button next to each commit-ish field that opens a popup
20+
listing the repo's local branches, remote-tracking branches, tags,
21+
and refs you've recently used in this repo. A live case-insensitive
22+
filter box narrows every group as you type; each row shows the
23+
ref's friendly name plus its tip's short SHA. The popup also
24+
includes an inline merge-base composer — fill two refs and click
25+
**Compute & use** to substitute the resulting SHA into the field —
26+
for ad-hoc "what did this branch add since it forked" comparisons
27+
without leaving the dialog. Freeform typing still works as a
28+
fallback. Enumeration happens off the UI thread so opening the
29+
picker on a large repo doesn't freeze the dialog. Resolves
30+
[#4](https://github.com/geevensingh/DiffViewer/issues/4).
31+
- **New Diff dialog: "Branch vs merge-base" mode.** A new top-level
32+
mode in the left rail wires the dominant PR-style review workflow
33+
("what did this branch add since it forked from main") into a
34+
one-click form. Takes a branch and a merge-base partner, resolves
35+
their most recent common ancestor on submit, and launches a diff
36+
with the merge-base on the left and the branch tip on the right —
37+
so additions land in the right pane the same way every other form
38+
produces. Surfaces "No common ancestor" inline when the two refs
39+
have unrelated histories, rather than silently disabling **OK**.
40+
Both commit-ish inputs get the same ref picker described above.
1741
- **File-list filter + per-file viewed checkbox.** A new bar above
1842
the display-mode toggle adds a case-insensitive substring filter
1943
(slash-insensitive: `foo/bar.cs` and `foo\bar.cs` both match) that

DiffViewer.Tests/Services/DiffModeRegistryTests.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,22 @@ namespace DiffViewer.Tests.Services;
77
public class DiffModeRegistryTests
88
{
99
[Fact]
10-
public void BuildDefault_ListsFourProvidersInExpectedOrder()
10+
public void BuildDefault_ListsFiveProvidersInExpectedOrder()
1111
{
1212
// Order is part of the contract — it's what the dialog's
1313
// left-rail ListBox renders top-to-bottom. "Working tree vs
14-
// HEAD" leads as the cheapest interaction.
14+
// HEAD" leads as the cheapest interaction; the local
15+
// comparison family stays grouped; "Branch vs merge-base"
16+
// (the dominant PR-style workflow) lives alongside the
17+
// local family above the cross-network GitHub-PR mode.
1518
var registry = DiffModeRegistry.BuildDefault();
1619

17-
registry.Providers.Should().HaveCount(4);
20+
registry.Providers.Should().HaveCount(5);
1821
registry.Providers[0].Id.Should().Be(WorkingTreeVsHeadProvider.ProviderId);
1922
registry.Providers[1].Id.Should().Be(WorkingTreeVsCommitProvider.ProviderId);
2023
registry.Providers[2].Id.Should().Be(CommitVsCommitProvider.ProviderId);
21-
registry.Providers[3].Id.Should().Be(GitHubPullRequestProvider.ProviderId);
24+
registry.Providers[3].Id.Should().Be(BranchVsMergeBaseProvider.ProviderId);
25+
registry.Providers[4].Id.Should().Be(GitHubPullRequestProvider.ProviderId);
2226
}
2327

2428
[Fact]
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
using DiffViewer.Services;
2+
using FluentAssertions;
3+
using LibGit2Sharp;
4+
using System.IO;
5+
using Xunit;
6+
7+
namespace DiffViewer.Tests.Services;
8+
9+
public class GitRefEnumeratorTests
10+
{
11+
[Fact]
12+
public void Enumerate_OnInvalidPath_ReturnsEmpty()
13+
{
14+
var bogus = Path.Combine(Path.GetTempPath(), "diffviewer-not-a-repo-" + Guid.NewGuid().ToString("N"));
15+
Directory.CreateDirectory(bogus);
16+
try
17+
{
18+
var sut = new LibGit2GitRefEnumerator();
19+
var result = sut.Enumerate(bogus);
20+
21+
result.Should().BeSameAs(RefEnumerationResult.Empty);
22+
}
23+
finally
24+
{
25+
Directory.Delete(bogus, recursive: true);
26+
}
27+
}
28+
29+
[Fact]
30+
public void Enumerate_OnEmptyOrNullPath_ReturnsEmpty()
31+
{
32+
var sut = new LibGit2GitRefEnumerator();
33+
sut.Enumerate("").Should().BeSameAs(RefEnumerationResult.Empty);
34+
sut.Enumerate(" ").Should().BeSameAs(RefEnumerationResult.Empty);
35+
}
36+
37+
[Fact]
38+
public void Enumerate_OnFreshRepoWithNoCommits_ReturnsEmptyLists()
39+
{
40+
using var t = new TempRepo();
41+
var sut = new LibGit2GitRefEnumerator();
42+
43+
var result = sut.Enumerate(t.Path);
44+
45+
// Unborn HEAD: no branches resolve a Tip, no tags exist yet.
46+
result.LocalBranches.Should().BeEmpty();
47+
result.RemoteBranches.Should().BeEmpty();
48+
result.Tags.Should().BeEmpty();
49+
}
50+
51+
[Fact]
52+
public void Enumerate_ReturnsLocalBranchesSortedAlphabetically()
53+
{
54+
using var t = new TempRepo();
55+
t.WriteFile("a.txt", "a\n");
56+
var c1 = t.InitialCommit("c1");
57+
t.CreateBranch("feature/zebra", c1);
58+
t.CreateBranch("feature/alpha", c1);
59+
60+
var sut = new LibGit2GitRefEnumerator();
61+
var result = sut.Enumerate(t.Path);
62+
63+
result.LocalBranches.Select(b => b.FriendlyName)
64+
.Should().ContainInOrder("feature/alpha", "feature/zebra")
65+
.And.HaveCount(3); // alpha, zebra, and the default branch
66+
result.LocalBranches.Should().AllSatisfy(b =>
67+
{
68+
b.TipSha.Should().Be(c1.Sha);
69+
b.TipShortSha.Should().Be(c1.Sha[..7]);
70+
});
71+
}
72+
73+
[Fact]
74+
public void Enumerate_SeparatesRemoteFromLocalBranches()
75+
{
76+
using var t = new TempRepo();
77+
t.WriteFile("a.txt", "a\n");
78+
var c1 = t.InitialCommit("c1");
79+
t.CreateRemoteTrackingBranch("origin", "feature/x", c1);
80+
81+
var sut = new LibGit2GitRefEnumerator();
82+
var result = sut.Enumerate(t.Path);
83+
84+
result.LocalBranches.Should().NotContain(b => b.FriendlyName.StartsWith("origin/"));
85+
result.RemoteBranches.Select(b => b.FriendlyName)
86+
.Should().Contain("origin/feature/x");
87+
}
88+
89+
[Fact]
90+
public void Enumerate_ExcludesOriginHeadFromRemoteBranches()
91+
{
92+
using var t = new TempRepo();
93+
t.WriteFile("a.txt", "a\n");
94+
var c1 = t.InitialCommit("c1");
95+
t.CreateRemoteTrackingBranch("origin", "master", c1);
96+
97+
// Create the synthetic origin/HEAD symbolic ref pointing at
98+
// origin/master — git fetch creates this automatically; we
99+
// install it manually here.
100+
using (var repo = new Repository(t.Path))
101+
{
102+
repo.Refs.Add("refs/remotes/origin/HEAD", "refs/remotes/origin/master", allowOverwrite: true);
103+
}
104+
105+
var sut = new LibGit2GitRefEnumerator();
106+
var result = sut.Enumerate(t.Path);
107+
108+
result.RemoteBranches.Should().NotContain(b => b.FriendlyName == "origin/HEAD");
109+
result.RemoteBranches.Should().Contain(b => b.FriendlyName == "origin/master");
110+
}
111+
112+
[Fact]
113+
public void Enumerate_ReturnsLightweightAndAnnotatedTags()
114+
{
115+
using var t = new TempRepo();
116+
t.WriteFile("a.txt", "a\n");
117+
var c1 = t.InitialCommit("c1");
118+
t.CreateLightweightTag("v0.1.0", c1);
119+
t.CreateAnnotatedTag("v0.2.0", c1, "release 0.2");
120+
121+
var sut = new LibGit2GitRefEnumerator();
122+
var result = sut.Enumerate(t.Path);
123+
124+
result.Tags.Select(x => x.FriendlyName)
125+
.Should().Contain(new[] { "v0.1.0", "v0.2.0" });
126+
result.Tags.Should().AllSatisfy(x =>
127+
{
128+
x.TipSha.Should().Be(c1.Sha);
129+
});
130+
}
131+
132+
[Fact]
133+
public void TryComputeMergeBase_ReturnsCommonAncestor()
134+
{
135+
using var t = new TempRepo();
136+
t.WriteFile("a.txt", "a\n");
137+
var root = t.InitialCommit("root");
138+
139+
t.CreateBranch("branch-a", root);
140+
t.Checkout("branch-a");
141+
t.WriteFile("a.txt", "a-changed\n");
142+
t.Commit("on branch-a");
143+
144+
// Default branch is still at root. Branch off another path
145+
// from root so both sides have moved past it.
146+
t.CreateBranch("branch-b", root);
147+
t.Checkout("branch-b");
148+
t.WriteFile("b.txt", "b\n");
149+
t.Commit("on branch-b");
150+
151+
var sut = new LibGit2GitRefEnumerator();
152+
var mb = sut.TryComputeMergeBase(t.Path, "branch-a", "branch-b");
153+
154+
mb.Should().Be(root.Sha);
155+
}
156+
157+
[Fact]
158+
public void TryComputeMergeBase_OnUnrelatedHistories_ReturnsNull()
159+
{
160+
using var t = new TempRepo();
161+
t.WriteFile("a.txt", "a\n");
162+
var c1 = t.InitialCommit("c1");
163+
164+
// Build a parentless commit via the libgit2 ObjectDatabase
165+
// primitives and tag it. That commit shares no ancestor with
166+
// c1, so the merge-base must come back null.
167+
using (var repo = new Repository(t.Path))
168+
{
169+
var tree = repo.Lookup<Commit>(c1.Sha)!.Tree;
170+
var orphan = repo.ObjectDatabase.CreateCommit(
171+
t.Author, t.Author, "orphan", tree,
172+
parents: Array.Empty<Commit>(),
173+
prettifyMessage: true);
174+
repo.Refs.Add("refs/heads/orphan", orphan.Sha);
175+
}
176+
177+
var sut = new LibGit2GitRefEnumerator();
178+
var mb = sut.TryComputeMergeBase(t.Path, c1.Sha, "orphan");
179+
180+
mb.Should().BeNull();
181+
}
182+
183+
[Fact]
184+
public void TryComputeMergeBase_OnUnresolvableRef_ReturnsNull()
185+
{
186+
using var t = new TempRepo();
187+
t.WriteFile("a.txt", "a\n");
188+
t.InitialCommit("c1");
189+
190+
var sut = new LibGit2GitRefEnumerator();
191+
192+
sut.TryComputeMergeBase(t.Path, "master", "does-not-exist").Should().BeNull();
193+
sut.TryComputeMergeBase(t.Path, "does-not-exist", "master").Should().BeNull();
194+
}
195+
196+
[Fact]
197+
public void TryComputeMergeBase_OnInvalidPath_ReturnsNull()
198+
{
199+
var sut = new LibGit2GitRefEnumerator();
200+
201+
sut.TryComputeMergeBase("", "a", "b").Should().BeNull();
202+
sut.TryComputeMergeBase(@"C:\nope-" + Guid.NewGuid().ToString("N"), "a", "b").Should().BeNull();
203+
}
204+
205+
[Fact]
206+
public void TryComputeMergeBase_OnEmptyRefArgs_ReturnsNull()
207+
{
208+
using var t = new TempRepo();
209+
t.WriteFile("a.txt", "a\n");
210+
t.InitialCommit("c1");
211+
212+
var sut = new LibGit2GitRefEnumerator();
213+
214+
sut.TryComputeMergeBase(t.Path, "", "master").Should().BeNull();
215+
sut.TryComputeMergeBase(t.Path, "master", " ").Should().BeNull();
216+
}
217+
}

0 commit comments

Comments
 (0)