You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Two tests in GitWorktreeReviewWorkspaceTabViewModelTests fail after #805's async refactoring. Both tests plant a "sentinel" commit in vm.CommitList.Commits, change vm.TargetBranch to trigger a refresh, then assert the sentinel was cleared. The assertion fails—the sentinel remains.
Impact: These tests block completion of #805. The core fix (moving LibGit2Sharp I/O to background threads) is implemented and 36/38 tests pass, but these 2 failures prevent commit.
Root Cause
The issue is a race condition between the cancellation-token propagation in RefreshAsync and the mutation of CommitList.Commits that lives on the ViewModel.
this.refreshCts?.Cancel();// Cancels any in-flight refreshthis.refreshCts=CancellationTokenSource.CreateLinkedTokenSource(ct);vartoken=this.refreshCts.Token;
Test pattern (ChangingTargetBranchTriggersCommitListRefresh, lines 233–269):
awaitWaitForInitialRefresh(vm);vm.CommitList.Commits.Add(sentinel);// Add sentinel to the *live* collection// Set up PropertyChanged listener...vm.TargetBranch="develop";// Triggers RefreshAsyncawaitrefreshCompleted.Task.WaitAsync(...);// Wait for IsRefreshing cycleAssert.Empty(vm.CommitList.Commits);// FAILS — sentinel still there
Why it fails:
When TargetBranch is set, it calls Lifetime.Run(this.RefreshAsync) (line 59).
This cancels the token already passed to any in-flight CommitList.RefreshAsync.
CommitList.RefreshAsync throws OperationCanceledException before reaching Commits.Clear() (line 70).
The outer catch (OperationCanceledException) in GitWorktreeReviewWorkspaceTabViewModel.RefreshAsync swallows the abort (line 169).
IsRefreshing goes false, the test thinks the refresh completed, but the sentinel was never cleared because the mutation site (Commits.Clear()) was skipped by cancellation.
The deeper issue: the refresh mutates state that is already visible in the UI (this.CommitList.Commits, this.FileList.Files, this.FileDiffs). A partially-executed refresh that is aborted mid-way therefore leaves the UI in an inconsistent state. Cancellation and shared mutable UI state are fundamentally incompatible without either (a) serializing writes or (b) writing to detached objects.
CommitList / FileList are readonly get-only properties; refresh mutates their internal collections in-place, so cancellation mid-refresh leaves stale/mixed data visible.
Tests assume a triggered refresh always completes and replaces the visible commit list.
Chosen Design: Build a fresh ViewModel per refresh; swap atomically on success
Keep the existing refreshCts.Cancel() + new-CTS pattern. The fix is to stop mutating the currently-bound CommitList / FileList / FileDiffs. Instead, each RefreshAsync invocation builds a newGitWorktreeCommitListViewModel, GitWorktreeFileListViewModel, and diffs list locally, populates them under the cancellation token, and — only if the token was not cancelled — assigns them to the public properties, raising PropertyChanged so the UI rebinds.
Cancelled refreshes work on detached objects that are never published. They can never corrupt the visible state because they never write back to this.CommitList / this.FileList / this.FileDiffs. Because each refresh operates on its own instance, concurrent refreshes touch disjoint objects — no serialization or semaphore is required.
Changes to GitWorktreeReviewWorkspaceTabViewModel
Make CommitList, FileList, FileDiffs settable, INotifyPropertyChanged-raising properties (not readonly getters):
privateGitWorktreeCommitListViewModelcommitList=new();privateGitWorktreeFileListViewModelfileList=new();privateObservableCollection<GitDiffViewModel>fileDiffs=new();publicGitWorktreeCommitListViewModelCommitList{
get =>this.commitList;privateset=>this.SetProperty(refthis.commitList,value);}publicGitWorktreeFileListViewModelFileList{
get =>this.fileList;privateset=>this.SetProperty(refthis.fileList,value);}publicObservableCollection<GitDiffViewModel>FileDiffs{
get =>this.fileDiffs;privateset=>this.SetProperty(refthis.fileDiffs,value);}
AXAML bindings such as {Binding CommitList.Commits} and {Binding FileList.Files} will re-resolve automatically when PropertyChanged fires.
Re-subscribe SelectedCommits.CollectionChanged / SelectedFiles.CollectionChanged on swap.
The current ctor subscribes to the initial CommitList.SelectedCommits and FileList.SelectedFiles. When we replace those VMs, we must unsubscribe from the old collections and subscribe to the new ones. A helper:
Rewrite RefreshAsync to build detached objects and swap on success:
publicasyncTaskRefreshAsync(CancellationTokenct=default){this.refreshCts?.Cancel();this.refreshCts?.Dispose();this.refreshCts=CancellationTokenSource.CreateLinkedTokenSource(ct);vartoken=this.refreshCts.Token;try{this.IsRefreshing=true;// Build detached VMs — never published if cancelled.varnewCommitList=newGitWorktreeCommitListViewModel();awaitnewCommitList.RefreshAsync(this.RepositoryPath,this.targetBranch,token);// Carry forward selection by OID from the currently-visible list.PreserveCommitSelection(this.CommitList,newCommitList);varselectedCommits=newCommitList.SelectedCommits.Count>0?(IReadOnlyList<GitCommitModel>)newCommitList.SelectedCommits:(IReadOnlyList<GitCommitModel>)newCommitList.Commits;varnewFileList=newGitWorktreeFileListViewModel();awaitnewFileList.RefreshAsync(this.RepositoryPath,selectedCommits,token);PreserveFileSelection(this.FileList,newFileList);varnewDiffs=awaitBuildFileDiffsAsync(newFileList,selectedCommits,token);token.ThrowIfCancellationRequested();// Atomic swap — the only writes back to *this* state.this.AttachCommitList(newCommitList);this.AttachFileList(newFileList);this.FileDiffs=newDiffs;}catch(OperationCanceledException){// Detached VMs simply go out of scope; visible state is untouched.}finally{if(!token.IsCancellationRequested){this.IsRefreshing=false;}}}
Key properties of this shape:
refreshCts.Cancel() remains — a superseded refresh aborts its background I/O quickly. But the abort only affects local variables; this.CommitList still holds the previously-published (fully-consistent) instance.
No SemaphoreSlim, no serialization. Two concurrent refreshes touch disjoint newCommitList / newFileList instances. Whichever one runs last through the swap wins; the earlier one either finishes first (and is immediately overwritten) or is cancelled (and its detached objects are discarded).
The visible CommitList is only ever replaced by a fully-populated instance. It is never observed in a half-cleared state.
RebuildFileDiffsAsync becomes BuildFileDiffsAsync — a pure builder that returns a new ObservableCollection<GitDiffViewModel> rather than mutating this.FileDiffs. Callers that currently mutate in place (property setters for SideBySide, FullFile, ContextLines, OnSelectedFilesChanged, OnSelectedCommitsChanged) all switch to: build a new collection locally, then assign to this.FileDiffs at the end (with a cancellation check just before the assignment).
DisposeAsync unchanged apart from unsubscribing from whichever CommitList/FileList is currently attached.
Changes to GitWorktreeCommitListViewModel / GitWorktreeFileListViewModel
Each is already parameterless-constructible and stateless apart from its two ObservableCollections, so no changes to their public shape are required. RefreshAsync is still called once on a freshly-constructed instance and populates the two collections.
Optionally, Clear() at the top of RefreshAsync can be removed since it will only ever be invoked on empty instances. This is a follow-up cleanup, not required for the fix.
Selection preservation
Because the VMs are replaced wholesale, the selection state that currently lives inside CommitList.SelectedCommits and FileList.SelectedFiles must be copied from the previous instance to the new one before the swap. Two small helpers (PreserveCommitSelection, PreserveFileSelection) match by OID / relative path, mirroring the in-place preservation logic that already exists at the top of each RefreshAsync. This is user-observable: without it, selection would reset on every refresh (including watcher-triggered refreshes), which is a regression from today's behaviour.
Considered Alternatives
Considered — superseded by replace-object approach: Serialize refreshes with a SemaphoreSlim
RefreshAsync in GitWorktreeReviewWorkspaceTabViewModel would wait for any in-flight refresh to finish before starting a new one, using a SemaphoreSlim(1, 1) and removing refreshCts entirely. Refreshes would be serialized rather than cancelled.
Rejected because:
It defeats the purpose of cancellation — a slow initial refresh delays every subsequent one, so typing rapidly in the branch dropdown queues up N full LibGit2Sharp scans instead of skipping to the latest.
The replace-object approach achieves the same "no torn state" guarantee without any locking: refreshes can run concurrently on disjoint objects, and only the winner is published.
Option 1: Non-cancellable commit list clear
Change CommitList.RefreshAsync to use CancellationToken.None when acquiring its semaphore, ensuring the clear always executes even when the outer token is cancelled.
Rejected: The outer ViewModel still cancels the token mid-flight. This is a band-aid, not a fix for the "cancel while mutating visible state" pattern.
Option 2: Defer cancellation until after Clear()
Cancel the previous refresh's I/O but not its UI update — let the old refresh finish its Commits.Clear() before the new one starts.
Rejected: Complex to implement correctly; still requires coordinating cancellation timing across two ViewModels, and still leaves a window where the UI shows an empty list.
Option 3: Rewrite tests to not rely on cancellation side effects
Remove the sentinel pattern; verify refresh by checking that Commits changed from its initial state.
Rejected: Fixes the symptom in tests without fixing the underlying race condition in production code — the UI can still flash a half-cleared list.
Expected Tests
Test Name
Class
What It Verifies
ChangingTargetBranchTriggersCommitListRefresh
GitWorktreeReviewWorkspaceTabViewModelTests
Changing TargetBranch replaces CommitList with a new instance whose Commits reflects the new target branch and does not contain the sentinel.
BranchDropdown_SelectBranch_UpdatesTargetBranch
GitWorktreeReviewWorkspaceTabViewModelTests
Selecting a branch from the dropdown updates TargetBranch and results in a fresh CommitList being published.
Cancelling a refresh (e.g. by triggering a second refresh before the first completes) does not mutate the currently-visible CommitList — the previous fully-populated instance is still the one bound to the UI.
RefreshAsync_ConcurrentRefreshes_LastOneWins
GitWorktreeReviewWorkspaceTabViewModelTests
Rapid successive TargetBranch changes cause the final CommitList to correspond to the last-set target branch; superseded refreshes never overwrite it.
Summary
Two tests in
GitWorktreeReviewWorkspaceTabViewModelTestsfail after #805's async refactoring. Both tests plant a "sentinel" commit invm.CommitList.Commits, changevm.TargetBranchto trigger a refresh, then assert the sentinel was cleared. The assertion fails—the sentinel remains.Failing tests:
ChangingTargetBranchTriggersCommitListRefresh(line 221)BranchDropdown_SelectBranch_UpdatesTargetBranch(line 1188)Impact: These tests block completion of #805. The core fix (moving LibGit2Sharp I/O to background threads) is implemented and 36/38 tests pass, but these 2 failures prevent commit.
Root Cause
The issue is a race condition between the cancellation-token propagation in
RefreshAsyncand the mutation ofCommitList.Commitsthat lives on the ViewModel.GitWorktreeReviewWorkspaceTabViewModel.cs—RefreshAsync(line 153–155):GitWorktreeCommitListViewModel.cs—RefreshAsync(line 17):Test pattern (
ChangingTargetBranchTriggersCommitListRefresh, lines 233–269):Why it fails:
TargetBranchis set, it callsLifetime.Run(this.RefreshAsync)(line 59).RefreshAsyncimmediately callsthis.refreshCts?.Cancel()(line 153).CommitList.RefreshAsync.CommitList.RefreshAsyncthrowsOperationCanceledExceptionbefore reachingCommits.Clear()(line 70).catch (OperationCanceledException)inGitWorktreeReviewWorkspaceTabViewModel.RefreshAsyncswallows the abort (line 169).IsRefreshinggoes false, the test thinks the refresh completed, but the sentinel was never cleared because the mutation site (Commits.Clear()) was skipped by cancellation.The deeper issue: the refresh mutates state that is already visible in the UI (
this.CommitList.Commits,this.FileList.Files,this.FileDiffs). A partially-executed refresh that is aborted mid-way therefore leaves the UI in an inconsistent state. Cancellation and shared mutable UI state are fundamentally incompatible without either (a) serializing writes or (b) writing to detached objects.Affected Files
Phantom.Workspaces\ViewModels\GitWorktreeReviewWorkspaceTabViewModel.csCommitList/FileListare readonly get-only properties; refresh mutates their internal collections in-place, so cancellation mid-refresh leaves stale/mixed data visible.Phantom.Workspaces\ViewModels\GitWorktreeCommitListViewModel.csthis.Commits/this.SelectedCommitsin-place; cancellation betweenClear()and repopulation would show an empty UI.Phantom.Workspaces\ViewModels\GitWorktreeFileListViewModel.csPhantom.Workspaces.Tests\GitWorktreeReviewWorkspaceTabViewModelTests.csChosen Design: Build a fresh ViewModel per refresh; swap atomically on success
Keep the existing
refreshCts.Cancel()+ new-CTS pattern. The fix is to stop mutating the currently-boundCommitList/FileList/FileDiffs. Instead, eachRefreshAsyncinvocation builds a newGitWorktreeCommitListViewModel,GitWorktreeFileListViewModel, and diffs list locally, populates them under the cancellation token, and — only if the token was not cancelled — assigns them to the public properties, raisingPropertyChangedso the UI rebinds.Cancelled refreshes work on detached objects that are never published. They can never corrupt the visible state because they never write back to
this.CommitList/this.FileList/this.FileDiffs. Because each refresh operates on its own instance, concurrent refreshes touch disjoint objects — no serialization or semaphore is required.Changes to
GitWorktreeReviewWorkspaceTabViewModelMake
CommitList,FileList,FileDiffssettable,INotifyPropertyChanged-raising properties (not readonly getters):AXAML bindings such as
{Binding CommitList.Commits}and{Binding FileList.Files}will re-resolve automatically whenPropertyChangedfires.Re-subscribe
SelectedCommits.CollectionChanged/SelectedFiles.CollectionChangedon swap.The current ctor subscribes to the initial
CommitList.SelectedCommitsandFileList.SelectedFiles. When we replace those VMs, we must unsubscribe from the old collections and subscribe to the new ones. A helper:Rewrite
RefreshAsyncto build detached objects and swap on success:Key properties of this shape:
refreshCts.Cancel()remains — a superseded refresh aborts its background I/O quickly. But the abort only affects local variables;this.CommitListstill holds the previously-published (fully-consistent) instance.SemaphoreSlim, no serialization. Two concurrent refreshes touch disjointnewCommitList/newFileListinstances. Whichever one runs last through the swap wins; the earlier one either finishes first (and is immediately overwritten) or is cancelled (and its detached objects are discarded).CommitListis only ever replaced by a fully-populated instance. It is never observed in a half-cleared state.RebuildFileDiffsAsyncbecomesBuildFileDiffsAsync— a pure builder that returns a newObservableCollection<GitDiffViewModel>rather than mutatingthis.FileDiffs. Callers that currently mutate in place (property setters forSideBySide,FullFile,ContextLines,OnSelectedFilesChanged,OnSelectedCommitsChanged) all switch to: build a new collection locally, then assign tothis.FileDiffsat the end (with a cancellation check just before the assignment).DisposeAsyncunchanged apart from unsubscribing from whicheverCommitList/FileListis currently attached.Changes to
GitWorktreeCommitListViewModel/GitWorktreeFileListViewModelObservableCollections, so no changes to their public shape are required.RefreshAsyncis still called once on a freshly-constructed instance and populates the two collections.Clear()at the top ofRefreshAsynccan be removed since it will only ever be invoked on empty instances. This is a follow-up cleanup, not required for the fix.Selection preservation
Because the VMs are replaced wholesale, the selection state that currently lives inside
CommitList.SelectedCommitsandFileList.SelectedFilesmust be copied from the previous instance to the new one before the swap. Two small helpers (PreserveCommitSelection,PreserveFileSelection) match by OID / relative path, mirroring the in-place preservation logic that already exists at the top of eachRefreshAsync. This is user-observable: without it, selection would reset on every refresh (including watcher-triggered refreshes), which is a regression from today's behaviour.Considered Alternatives
Considered — superseded by replace-object approach: Serialize refreshes with a
SemaphoreSlimRefreshAsyncinGitWorktreeReviewWorkspaceTabViewModelwould wait for any in-flight refresh to finish before starting a new one, using aSemaphoreSlim(1, 1)and removingrefreshCtsentirely. Refreshes would be serialized rather than cancelled.Rejected because:
Option 1: Non-cancellable commit list clear
Change
CommitList.RefreshAsyncto useCancellationToken.Nonewhen acquiring its semaphore, ensuring the clear always executes even when the outer token is cancelled.Rejected: The outer ViewModel still cancels the token mid-flight. This is a band-aid, not a fix for the "cancel while mutating visible state" pattern.
Option 2: Defer cancellation until after
Clear()Cancel the previous refresh's I/O but not its UI update — let the old refresh finish its
Commits.Clear()before the new one starts.Rejected: Complex to implement correctly; still requires coordinating cancellation timing across two ViewModels, and still leaves a window where the UI shows an empty list.
Option 3: Rewrite tests to not rely on cancellation side effects
Remove the sentinel pattern; verify refresh by checking that
Commitschanged from its initial state.Rejected: Fixes the symptom in tests without fixing the underlying race condition in production code — the UI can still flash a half-cleared list.
Expected Tests
ChangingTargetBranchTriggersCommitListRefreshGitWorktreeReviewWorkspaceTabViewModelTestsTargetBranchreplacesCommitListwith a new instance whoseCommitsreflects the new target branch and does not contain the sentinel.BranchDropdown_SelectBranch_UpdatesTargetBranchGitWorktreeReviewWorkspaceTabViewModelTestsTargetBranchand results in a freshCommitListbeing published.RefreshAsync_CancelledMidflight_LeavesVisibleCommitListUnchangedGitWorktreeReviewWorkspaceTabViewModelTestsCommitList— the previous fully-populated instance is still the one bound to the UI.RefreshAsync_ConcurrentRefreshes_LastOneWinsGitWorktreeReviewWorkspaceTabViewModelTestsTargetBranchchanges cause the finalCommitListto correspond to the last-set target branch; superseded refreshes never overwrite it.RefreshAsync_Success_RaisesPropertyChangedForCommitListAndFileListGitWorktreeReviewWorkspaceTabViewModelTestsPropertyChangedforCommitList,FileList, andFileDiffs.RefreshAsync_PreservesSelectionByOidGitWorktreeReviewWorkspaceTabViewModelTestsCommitListcontains a commit with the same OID as a previously-selected commit, it is re-selected in the new instance.Context
This bug blocks #805 (LibGit2Sharp operations block UI thread). The core fix is complete and working, but these 2 test failures prevent final commit.