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 |
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)
Root Cause
Three ViewModels with blocking LibGit2Sharp calls
All three ViewModel
RefreshAsyncmethods are fully synchronous — they perform all git I/O and returnTask.CompletedTaskwithout ever yielding.awaiton these calls returns immediately without switching threads.GitWorktreeFileListViewModel.RefreshAsync(lines 16–102)new Repository(repositoryPath)repo.Diff.Compare<Patch>(repo.Head.Tip?.Tree, DiffTargets.WorkingDirectory)repo.Diff.Compare<Patch>(repo.Head.Tip?.Tree, DiffTargets.Index)repo.Lookup<Commit>(commit.Oid)repo.Diff.Compare<Patch>(parent.Tree, c.Tree)GitWorktreeCommitListViewModel.RefreshAsync(lines 15–102)new Repository(repositoryPath)repo.RetrieveStatus(new StatusOptions())repo.Branches[...],repo.Lookup<Commit>(...),repo.Commits.QueryBy(filter)loopGitWorktreeReviewWorkspaceTabViewModel— constructor ANDRefreshAsyncThe constructor itself (lines 33–34) calls two helpers directly on the UI thread before
Lifetime.Run:GetDefaultTargetBranch(repositoryPath)(line 346):new Repository(...)+ branch lookupLoadBranchNames(repositoryPath, ...)(line 377):new Repository(...)+ full branch enumerationRebuildFileDiffsAsync(lines 195–264) has a further 5 blocking calls.ViewModelLifetime.Rundoes not switch threadsRunCoreAsyncinvokeswork(cts.Token)synchronously before anyawait. Because theworkdelegates returnTask.CompletedTaskwithout yielding,ConfigureAwait(false)never takes effect — all git I/O runs on the calling (UI) thread.Affected Files
Phantom.Workspaces/ViewModels/GitWorktreeFileListViewModel.cs(lines 16–102)Task.RunPhantom.Workspaces/ViewModels/GitWorktreeCommitListViewModel.cs(lines 15–102)Task.RunPhantom.Workspaces/ViewModels/GitWorktreeReviewWorkspaceTabViewModel.cs(lines 33–34, 195–264, 346, 377)RebuildFileDiffsAsync+ helpers synchronous, noTask.RunDesign / Fix
Wrap all blocking LibGit2Sharp sections in
await Task.Run(...)— the same pattern already used correctly inEntityBrowserWorkspaceTabViewModel(line 559) andJsonValidationViewModel(line 74).GitWorktreeFileListViewModel.RefreshAsyncGitWorktreeCommitListViewModel.RefreshAsyncSame pattern: wrap all
new Repository(...)and query calls inawait Task.Run(...).GitWorktreeReviewWorkspaceTabViewModelconstructorMove
GetDefaultTargetBranchandLoadBranchNamesout of the constructor body and intoRefreshAsync(or a newInitializeAsyncstep insideLifetime.Run), so they run inside theTask.Runblock rather than directly on the constructor call thread.RebuildFileDiffsAsyncWrap the
new Repository(...)and alldiff.Compare<Patch>(...)calls inawait Task.Run(...).Thread safety note
ObservableCollectionmutations must remain on the UI thread. TheTask.Runlambda should collect plain data objects (strings, lists, value types) and return them; the ViewModel then applies results to its observable collections afterawait Task.Run(...)returns (back on the UI thread, sinceLifetime.RunCoreAsyncdoes notConfigureAwait(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)RefreshAsync_RunsOnBackgroundThread_NotUIThreadGitWorktreeFileListViewModelTestsRefreshAsyncexecute on a thread-pool thread, not the calling threadRefreshAsync_ObservableCollectionUpdates_HappenOnCallingThreadGitWorktreeFileListViewModelTestsawait RefreshAsync(...),Filesis populated and the update occurred on the original (UI) threadCommitList_RefreshAsync_RunsOnBackgroundThread_NotUIThreadGitWorktreeReviewWorkspaceTabViewModelTestsCommitList.RefreshAsyncdoes not block the calling threadConstructor_GitOperations_DoNotBlockCallingThreadGitWorktreeReviewWorkspaceTabViewModelTestsGitWorktreeReviewWorkspaceTabViewModelreturns immediately without blocking the calling thread on git I/ORebuildFileDiffsAsync_RunsOnBackgroundThread_NotUIThreadGitWorktreeReviewWorkspaceTabViewModelTestsRebuildFileDiffsAsyncexecutes LibGit2Sharp I/O off the UI thread