Skip to content
Merged
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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,30 @@ body. Keep section headings exact and write notes in Markdown.

## [Unreleased]

### Added

- **Worktree switching.** The repository-path row in the "New diff"
dialog gained a **Worktree…** button listing every worktree of the
repository you typed — main first, each showing its checked-out
branch and path — so pointing a diff at a different checkout no
longer means remembering where `git worktree add` put it. The
context bar gained a matching **Worktree** button that re-opens the
comparison you are already looking at, rooted at another worktree,
with both sides unchanged. Worktrees whose directory has been
deleted are listed as unavailable rather than silently omitted.

### Changed

- **Worktrees are now identifiable in the UI.** DiffViewer already
worked when pointed at a linked worktree, but nothing on screen said
so. The recent-contexts dropdown labelled rows with the leaf
directory name, which for a worktree is usually the branch name and
never mentions the repository — two worktrees of one repo were
effectively indistinguishable. Recent rows now render as
`DiffViewer [feature-x] · HEAD → WT`, and the window title appends
the worktree name after the path. Rows saved by earlier versions keep
working and pick up their labels the next time that diff is opened.

## [1.9.0] - 2026-06-06

### Added
Expand Down
64 changes: 62 additions & 2 deletions DiffViewer.Tests/RecentContexts/RecentContextItemTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,72 @@ public void Tooltip_PrRow_IncludesPullRequestUrl()
item.Tooltip.Should().Contain("Last resolved head:");
}

private static RecentLaunchContext MakeLocal(string repoPath, string leftRef)
[Fact]
public void Title_WhenRowPointsAtALinkedWorktree_ShowsRepositoryAndWorktreeName()
{
// The worktree directory is named after the branch and lives
// nowhere near the repo, so the path leaf alone ("wt-feature-x")
// never mentions the repository the user is working in.
var ctx = MakeLocal(
@"C:\worktrees\wt-feature-x",
leftRef: "HEAD",
repositoryName: "DiffViewer",
worktreeName: "feature-x");
var item = new RecentContextItem(ctx);

item.Title.Should().Be("DiffViewer [feature-x] · HEAD → WT");
}

[Fact]
public void Title_WhenRowPointsAtTheMainWorktree_OmitsTheBracketedName()
{
var ctx = MakeLocal(
@"C:\repos\diffviewer",
leftRef: "main",
repositoryName: "DiffViewer");
var item = new RecentContextItem(ctx);

item.Title.Should().Be("DiffViewer · main → WT");
}

[Fact]
public void Title_WhenRowPredatesWorktreeLabels_FallsBackToThePathLeaf()
{
// Rows written by an older binary carry no labels; they must keep
// rendering rather than showing a blank repository name.
var ctx = MakeLocal(@"C:\repos\diffviewer", leftRef: "main");
var item = new RecentContextItem(ctx);

item.Title.Should().Be("diffviewer · main → WT");
}

[Fact]
public void Title_ForAPullRequestRowInAWorktree_StillShowsTheWorktreeName()
{
var left = new DiffSide.CommitIsh("abc1234");
var right = new DiffSide.CommitIsh("def5678");
var id = ContextIdentityFactory.Create(@"C:\worktrees\wt-feature-x", left, right);
var ctx = new RecentLaunchContext(
id, left, right, DateTimeOffset.UtcNow,
new PullRequestRef("github.com", "geevensingh", "diffviewer", 42),
RepositoryName: "DiffViewer",
WorktreeName: "feature-x");

new RecentContextItem(ctx).Title
.Should().Be("DiffViewer [feature-x] · PR geevensingh/diffviewer#42");
}

private static RecentLaunchContext MakeLocal(
string repoPath,
string leftRef,
string? repositoryName = null,
string? worktreeName = null)
{
var left = new DiffSide.CommitIsh(leftRef);
var right = new DiffSide.WorkingTree();
var id = ContextIdentityFactory.Create(repoPath, left, right);
return new RecentLaunchContext(id, left, right, DateTimeOffset.UtcNow);
return new RecentLaunchContext(
id, left, right, DateTimeOffset.UtcNow, null, repositoryName, worktreeName);
}

private static RecentLaunchContext MakePr(string repoPath, PullRequestRef pr)
Expand Down
177 changes: 177 additions & 0 deletions DiffViewer.Tests/RecentContexts/RecentContextsServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,185 @@ public RecentContextsServiceTests()
public void Dispose()
{
try { if (File.Exists(_path)) File.Delete(_path); } catch { /* best-effort */ }
foreach (var directory in _scratchDirectories)
{
try { Directory.Delete(directory, recursive: true); } catch { /* best-effort */ }
}
}

private readonly List<string> _scratchDirectories = new();

/// <summary>Build a synthetic checkout on disk: a working directory
/// whose <c>.git</c> is either a real directory (main worktree) or a
/// pointer file into a shared repo's administrative directory
/// (linked worktree). Enough for the label capture on the recents
/// write path, which reads files rather than opening libgit2.</summary>
private string CreateSyntheticCheckout(string repositoryName, string? worktreeName = null)
{
var root = Path.Combine(Path.GetTempPath(), $"recents-repo-{Guid.NewGuid():N}");
_scratchDirectories.Add(root);

var repository = Path.Combine(root, repositoryName);
var commonGitDir = Path.Combine(repository, ".git");
Directory.CreateDirectory(commonGitDir);

if (worktreeName is null) return repository;

var administrative = Path.Combine(commonGitDir, "worktrees", worktreeName);
Directory.CreateDirectory(administrative);
File.WriteAllText(Path.Combine(administrative, "commondir"), "../..\n");

// The worktree directory name deliberately differs from the
// worktree's git name, which is the case that makes the path
// leaf useless as a label.
var worktreeDirectory = Path.Combine(root, "checkouts", $"wt-{worktreeName}");
Directory.CreateDirectory(worktreeDirectory);
File.WriteAllText(Path.Combine(worktreeDirectory, ".git"), $"gitdir: {administrative}\n");
return worktreeDirectory;
}

[Fact]
public async Task RecordLaunchAsync_ForAMainWorktree_StampsTheRepositoryNameAndNoWorktreeName()
{
var repoPath = CreateSyntheticCheckout("DiffViewer");
var svc = new RecentContextsService(_path);

await svc.RecordLaunchAsync(
ContextIdentityFactory.Create(repoPath, Left, Right), Left, Right);

svc.Current.Should().ContainSingle();
svc.Current[0].RepositoryName.Should().Be("DiffViewer");
svc.Current[0].WorktreeName.Should().BeNull();
}

[Fact]
public async Task RecordLaunchAsync_ForALinkedWorktree_StampsBothLabels()
{
var worktreePath = CreateSyntheticCheckout("DiffViewer", worktreeName: "feature-x");
var svc = new RecentContextsService(_path);

await svc.RecordLaunchAsync(
ContextIdentityFactory.Create(worktreePath, Left, Right), Left, Right);

svc.Current[0].RepositoryName.Should().Be("DiffViewer");
svc.Current[0].WorktreeName.Should().Be("feature-x");
}

[Fact]
public async Task RecordLaunchAsync_PersistsTheLabelsToDisk()
{
var worktreePath = CreateSyntheticCheckout("DiffViewer", worktreeName: "feature-x");
var svc = new RecentContextsService(_path);

await svc.RecordLaunchAsync(
ContextIdentityFactory.Create(worktreePath, Left, Right), Left, Right);

var reloaded = new RecentContextsService(_path);
await reloaded.LoadAsync();

reloaded.Current[0].RepositoryName.Should().Be("DiffViewer");
reloaded.Current[0].WorktreeName.Should().Be("feature-x");
}

[Fact]
public async Task RecordLaunchAsync_ReRecordingAnUnlabeledRow_HealsItInPlace()
{
// A row written before worktree labelling existed carries none.
// Re-launching the same diff must fill them in rather than
// leaving a permanently unlabeled duplicate-looking entry.
var worktreePath = CreateSyntheticCheckout("DiffViewer", worktreeName: "feature-x");
var identity = ContextIdentityFactory.Create(worktreePath, Left, Right);

await RecentsStore.ReadAndMutateAsync(_path, _ => RecentsDoc.From(new[]
{
new RecentLaunchContext(identity, Left, Right, DateTimeOffset.UtcNow.AddDays(-1)),
}));

var svc = new RecentContextsService(_path);
await svc.LoadAsync();
svc.Current.Should().ContainSingle();
svc.Current[0].RepositoryName.Should().BeNull("precondition: the legacy row is unlabeled");

await svc.RecordLaunchAsync(identity, Left, Right);

svc.Current.Should().ContainSingle("re-recording bumps the existing row rather than adding one");
svc.Current[0].RepositoryName.Should().Be("DiffViewer");
svc.Current[0].WorktreeName.Should().Be("feature-x");
}

[Fact]
public async Task RecordLaunchAsync_RunsTheLabelProbeOffTheCallingThread()
{
// The coordinator awaits this from the UI thread during a context
// swap, and the gate completes synchronously when uncontended, so
// a synchronous probe would stat the repo path on the dispatcher.
// A repo on an offline share would then freeze the window.
var repoPath = CreateSyntheticCheckout("DiffViewer", worktreeName: "feature-x");
var callingThreadId = Environment.CurrentManagedThreadId;
var probeThreadId = callingThreadId;

var svc = new RecentContextsService(_path, labelRunner: probe => Task.Run(() =>
{
probeThreadId = Environment.CurrentManagedThreadId;
return probe();
}));

await svc.RecordLaunchAsync(
ContextIdentityFactory.Create(repoPath, Left, Right), Left, Right);

probeThreadId.Should().NotBe(callingThreadId);
// ...and the labels still land, so the move didn't cost behaviour.
svc.Current[0].RepositoryName.Should().Be("DiffViewer");
svc.Current[0].WorktreeName.Should().Be("feature-x");
}

[Fact]
public async Task RecordLaunchAsync_YieldsBeforeProbingTheFileSystem()
{
// Guards the specific trap: `_gate.WaitAsync` completes
// synchronously when uncontended, so anything before the first
// real await stays on the caller's thread. Gated on a TCS rather
// than Task.Yield so the assertion is deterministic — the probe
// provably cannot have run when we check.
var repoPath = CreateSyntheticCheckout("DiffViewer");
var release = new TaskCompletionSource();
var probed = false;

var svc = new RecentContextsService(_path, labelRunner: async probe =>
{
await release.Task;
probed = true;
return probe();
});

var pending = svc.RecordLaunchAsync(
ContextIdentityFactory.Create(repoPath, Left, Right), Left, Right);

probed.Should().BeFalse("the probe must not have run synchronously on the caller");
release.SetResult();
await pending;
probed.Should().BeTrue();
}

[Fact]
public async Task RecordLaunchAsync_ForAPathThatIsNotARepository_LeavesTheLabelsNull()
{
// The label lookup is best-effort; an unreadable path must not
// fail the launch record.
var svc = new RecentContextsService(_path);
var identity = ContextIdentityFactory.Create(
Path.Combine(Path.GetTempPath(), $"not-a-repo-{Guid.NewGuid():N}"), Left, Right);

await svc.RecordLaunchAsync(identity, Left, Right);

svc.Current.Should().ContainSingle();
svc.Current[0].RepositoryName.Should().BeNull();
svc.Current[0].WorktreeName.Should().BeNull();
}

private static readonly DiffSide Left = new DiffSide.CommitIsh("HEAD");
private static readonly DiffSide Right = new DiffSide.WorkingTree();

[Fact]
public void Current_BeforeLoad_IsEmpty()
{
Expand Down
Loading