Skip to content

Bug: git operations (LibGit2Sharp) block the UI thread in worktree review ViewModels #805

Description

@JoshuaRowePhantom

Summary

LibGit2Sharp repository I/O — new Repository(...), diff.Compare<Patch>(...), repo.Commits.QueryBy(...), repo.RetrieveStatus(...) — is executed synchronously on the Avalonia UI thread in three ViewModels. This blocks the UI message pump, freezing the application while git operations complete.

Stack Trace (from crash report)

LibGit2Sharp.Core.Proxy.git_diff_tree_to_workdir
LibGit2Sharp.Diff.Compare<Patch>(oldTree, DiffTargets.WorkingDirectory)
GitWorktreeFileListViewModel.RefreshAsync(repositoryPath, selectedCommits, ct)  ← Line 33
GitWorktreeReviewWorkspaceTabViewModel.RefreshAsync(ct)  ← Line 166
ViewModelLifetime.RunCoreAsync(work)  ← Line 32
ViewModelLifetime.Run(work)  ← Line 25
GitWorktreeReviewWorkspaceTabViewModel..ctor(entityViewModel)  ← Line 46
ReviewWorktreeShortcutHandler.Handle(...)
... [Avalonia UI message pump]

Root Cause

Three ViewModels with blocking LibGit2Sharp calls

All three ViewModel RefreshAsync methods are fully synchronous — they perform all git I/O and return Task.CompletedTask without ever yielding. await on these calls returns immediately without switching threads.

GitWorktreeFileListViewModel.RefreshAsync (lines 16–102)

Line Blocking call
24 new Repository(repositoryPath)
33 repo.Diff.Compare<Patch>(repo.Head.Tip?.Tree, DiffTargets.WorkingDirectory)
41 repo.Diff.Compare<Patch>(repo.Head.Tip?.Tree, DiffTargets.Index)
49 repo.Lookup<Commit>(commit.Oid)
52 repo.Diff.Compare<Patch>(parent.Tree, c.Tree)

GitWorktreeCommitListViewModel.RefreshAsync (lines 15–102)

Line Blocking call
25 new Repository(repositoryPath)
28 repo.RetrieveStatus(new StatusOptions())
33–42 repo.Branches[...], repo.Lookup<Commit>(...), repo.Commits.QueryBy(filter) loop

GitWorktreeReviewWorkspaceTabViewModel — constructor AND RefreshAsync

The constructor itself (lines 33–34) calls two helpers directly on the UI thread before Lifetime.Run:

  • GetDefaultTargetBranch(repositoryPath) (line 346): new Repository(...) + branch lookup
  • LoadBranchNames(repositoryPath, ...) (line 377): new Repository(...) + full branch enumeration

RebuildFileDiffsAsync (lines 195–264) has a further 5 blocking calls.

ViewModelLifetime.Run does not switch threads

// ViewModelLifetime.cs:23-32
public void Run(Func<CancellationToken, Task> work)
{
    tasks.Add(new TaskAdapter(RunCoreAsync(work)));
}

private async Task RunCoreAsync(Func<CancellationToken, Task> work)
{
    try { await work(cts.Token).ConfigureAwait(false); }
    catch (OperationCanceledException) { }
}

RunCoreAsync invokes work(cts.Token) synchronously before any await. Because the work delegates return Task.CompletedTask without yielding, ConfigureAwait(false) never takes effect — all git I/O runs on the calling (UI) thread.

Affected Files

File Issue
Phantom.Workspaces/ViewModels/GitWorktreeFileListViewModel.cs (lines 16–102) All LibGit2Sharp calls synchronous, no Task.Run
Phantom.Workspaces/ViewModels/GitWorktreeCommitListViewModel.cs (lines 15–102) All LibGit2Sharp calls synchronous, no Task.Run
Phantom.Workspaces/ViewModels/GitWorktreeReviewWorkspaceTabViewModel.cs (lines 33–34, 195–264, 346, 377) Constructor + RebuildFileDiffsAsync + helpers synchronous, no Task.Run

Design / Fix

Wrap all blocking LibGit2Sharp sections in await Task.Run(...) — the same pattern already used correctly in EntityBrowserWorkspaceTabViewModel (line 559) and JsonValidationViewModel (line 74).

GitWorktreeFileListViewModel.RefreshAsync

public async Task RefreshAsync(string repositoryPath, IReadOnlyList<GitCommitModel> selectedCommits, CancellationToken ct = default)
{
    var results = await Task.Run(() =>
    {
        using var repo = new Repository(repositoryPath);
        // ... all LibGit2Sharp calls ...
        return collectedResults;
    }, ct);

    // Update observable collections on the calling (UI) thread from results
    this.Files.Clear();
    foreach (var item in results) this.Files.Add(item);
}

GitWorktreeCommitListViewModel.RefreshAsync

Same pattern: wrap all new Repository(...) and query calls in await Task.Run(...).

GitWorktreeReviewWorkspaceTabViewModel constructor

Move GetDefaultTargetBranch and LoadBranchNames out of the constructor body and into RefreshAsync (or a new InitializeAsync step inside Lifetime.Run), so they run inside the Task.Run block rather than directly on the constructor call thread.

RebuildFileDiffsAsync

Wrap the new Repository(...) and all diff.Compare<Patch>(...) calls in await Task.Run(...).

Thread safety note

ObservableCollection mutations must remain on the UI thread. The Task.Run lambda should collect plain data objects (strings, lists, value types) and return them; the ViewModel then applies results to its observable collections after await Task.Run(...) returns (back on the UI thread, since Lifetime.RunCoreAsync does not ConfigureAwait(false) in a way that would strand the continuation on a thread-pool thread for the collection-update step).

Expected Tests

Test classes: GitWorktreeFileListViewModelTests (existing, Phantom.Workspaces.Tests); GitWorktreeReviewWorkspaceTabViewModelTests (existing, Phantom.Workspaces.Tests)

Test Name Class What It Verifies
RefreshAsync_RunsOnBackgroundThread_NotUIThread GitWorktreeFileListViewModelTests The LibGit2Sharp calls inside RefreshAsync execute on a thread-pool thread, not the calling thread
RefreshAsync_ObservableCollectionUpdates_HappenOnCallingThread GitWorktreeFileListViewModelTests After await RefreshAsync(...), Files is populated and the update occurred on the original (UI) thread
CommitList_RefreshAsync_RunsOnBackgroundThread_NotUIThread GitWorktreeReviewWorkspaceTabViewModelTests CommitList.RefreshAsync does not block the calling thread
Constructor_GitOperations_DoNotBlockCallingThread GitWorktreeReviewWorkspaceTabViewModelTests Constructing GitWorktreeReviewWorkspaceTabViewModel returns immediately without blocking the calling thread on git I/O
RebuildFileDiffsAsync_RunsOnBackgroundThread_NotUIThread GitWorktreeReviewWorkspaceTabViewModelTests RebuildFileDiffsAsync executes LibGit2Sharp I/O off the UI thread

Metadata

Metadata

Assignees

No one assigned

    Labels

    verifiedIssue has been verifiedverified-locallyImplementation has been verified locally

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions