Skip to content

GitWorktreeReviewWorkspaceTabViewModel tests fail when TargetBranch changed immediately after adding sentinel commits #888

Description

@JoshuaRowePhantom

Summary

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.

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 RefreshAsync and the mutation of CommitList.Commits that lives on the ViewModel.

GitWorktreeReviewWorkspaceTabViewModel.csRefreshAsync (line 153–155):

this.refreshCts?.Cancel();  // Cancels any in-flight refresh
this.refreshCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var token = this.refreshCts.Token;

GitWorktreeCommitListViewModel.csRefreshAsync (line 17):

ct.ThrowIfCancellationRequested();

Test pattern (ChangingTargetBranchTriggersCommitListRefresh, lines 233–269):

await WaitForInitialRefresh(vm);
vm.CommitList.Commits.Add(sentinel);  // Add sentinel to the *live* collection
// Set up PropertyChanged listener...
vm.TargetBranch = "develop";          // Triggers RefreshAsync
await refreshCompleted.Task.WaitAsync(...);  // Wait for IsRefreshing cycle
Assert.Empty(vm.CommitList.Commits);  // FAILS — sentinel still there

Why it fails:

  1. When TargetBranch is set, it calls Lifetime.Run(this.RefreshAsync) (line 59).
  2. RefreshAsync immediately calls this.refreshCts?.Cancel() (line 153).
  3. This cancels the token already passed to any in-flight CommitList.RefreshAsync.
  4. CommitList.RefreshAsync throws OperationCanceledException before reaching Commits.Clear() (line 70).
  5. The outer catch (OperationCanceledException) in GitWorktreeReviewWorkspaceTabViewModel.RefreshAsync swallows the abort (line 169).
  6. 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.


Affected Files

File Issue
Phantom.Workspaces\ViewModels\GitWorktreeReviewWorkspaceTabViewModel.cs CommitList / FileList are readonly get-only properties; refresh mutates their internal collections in-place, so cancellation mid-refresh leaves stale/mixed data visible.
Phantom.Workspaces\ViewModels\GitWorktreeCommitListViewModel.cs Populates this.Commits / this.SelectedCommits in-place; cancellation between Clear() and repopulation would show an empty UI.
Phantom.Workspaces\ViewModels\GitWorktreeFileListViewModel.cs Same in-place-mutation pattern as the commit list.
Phantom.Workspaces.Tests\GitWorktreeReviewWorkspaceTabViewModelTests.cs 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 new GitWorktreeCommitListViewModel, 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

  1. Make CommitList, FileList, FileDiffs settable, INotifyPropertyChanged-raising properties (not readonly getters):

    private GitWorktreeCommitListViewModel commitList = new();
    private GitWorktreeFileListViewModel fileList = new();
    private ObservableCollection<GitDiffViewModel> fileDiffs = new();
    
    public GitWorktreeCommitListViewModel CommitList
    {
        get => this.commitList;
        private set => this.SetProperty(ref this.commitList, value);
    }
    
    public GitWorktreeFileListViewModel FileList
    {
        get => this.fileList;
        private set => this.SetProperty(ref this.fileList, value);
    }
    
    public ObservableCollection<GitDiffViewModel> FileDiffs
    {
        get => this.fileDiffs;
        private set => this.SetProperty(ref this.fileDiffs, value);
    }

    AXAML bindings such as {Binding CommitList.Commits} and {Binding FileList.Files} will re-resolve automatically when PropertyChanged fires.

  2. 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:

    private void AttachCommitList(GitWorktreeCommitListViewModel newList)
    {
        if (this.commitList is { } old)
        {
            old.SelectedCommits.CollectionChanged -= this.OnSelectedCommitsChanged;
        }
        this.CommitList = newList;
        newList.SelectedCommits.CollectionChanged += this.OnSelectedCommitsChanged;
        this.RaisePropertyChanged(nameof(this.FileListHeader));
    }
    
    private void AttachFileList(GitWorktreeFileListViewModel newList)
    {
        if (this.fileList is { } old)
        {
            old.SelectedFiles.CollectionChanged -= this.OnSelectedFilesChanged;
        }
        this.FileList = newList;
        newList.SelectedFiles.CollectionChanged += this.OnSelectedFilesChanged;
    }
  3. Rewrite RefreshAsync to build detached objects and swap on success:

    public async Task RefreshAsync(CancellationToken ct = default)
    {
        this.refreshCts?.Cancel();
        this.refreshCts?.Dispose();
        this.refreshCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        var token = this.refreshCts.Token;
    
        try
        {
            this.IsRefreshing = true;
    
            // Build detached VMs — never published if cancelled.
            var newCommitList = new GitWorktreeCommitListViewModel();
            await newCommitList.RefreshAsync(this.RepositoryPath, this.targetBranch, token);
    
            // Carry forward selection by OID from the currently-visible list.
            PreserveCommitSelection(this.CommitList, newCommitList);
    
            var selectedCommits = newCommitList.SelectedCommits.Count > 0
                ? (IReadOnlyList<GitCommitModel>)newCommitList.SelectedCommits
                : (IReadOnlyList<GitCommitModel>)newCommitList.Commits;
    
            var newFileList = new GitWorktreeFileListViewModel();
            await newFileList.RefreshAsync(this.RepositoryPath, selectedCommits, token);
            PreserveFileSelection(this.FileList, newFileList);
    
            var newDiffs = await BuildFileDiffsAsync(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.
  4. 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).

  5. 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.
  • It requires taking a lock around a long-running I/O operation, which is exactly the kind of pattern Bug: git operations (LibGit2Sharp) block the UI thread in worktree review ViewModels #805 was trying to avoid.
  • 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.
RefreshAsync_CancelledMidflight_LeavesVisibleCommitListUnchanged GitWorktreeReviewWorkspaceTabViewModelTests 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.
RefreshAsync_Success_RaisesPropertyChangedForCommitListAndFileList GitWorktreeReviewWorkspaceTabViewModelTests A successful refresh raises PropertyChanged for CommitList, FileList, and FileDiffs.
RefreshAsync_PreservesSelectionByOid GitWorktreeReviewWorkspaceTabViewModelTests When the new CommitList contains 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdiagnosedRoot cause identifiedverifiedIssue 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