Skip to content

Add worktree labels to the UI and worktree switching to the dialog - #24

Merged
geevensingh merged 7 commits into
masterfrom
geevensingh-worktree-support
Sep 8, 2026
Merged

Add worktree labels to the UI and worktree switching to the dialog#24
geevensingh merged 7 commits into
masterfrom
geevensingh-worktree-support

Conversation

@geevensingh

@geevensingh geevensingh commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Why

DiffViewer already worked when pointed at a linked worktree, but nothing on screen acknowledged it and there was no way to get to one except typing its path from memory.

The visible symptom was in the recents dropdown. Row titles came from Path.GetFileName(repoPath), which for a worktree is usually the branch name and never mentions the repository. A worktree at ...\copilot-worktrees\DiffViewer\feature-x rendered as feature-x - HEAD -> WT, so two worktrees of one repo were effectively indistinguishable and the word "DiffViewer" appeared nowhere.

What changed

Legibility. Recent rows now render as DiffViewer [feature-x] - HEAD -> WT, and the window title appends the worktree name after the path. The labels are captured at launch time and persisted alongside the existing display-only fields, so the dropdown does no disk IO at render time. Rows written by earlier versions load with null labels, fall back to the old rendering, and heal on next launch.

Switching. The repo-path row in the New diff dialog gained a Worktree... button, and the context bar gained a matching Worktree button that re-opens the comparison you are already looking at rooted at a different checkout, with both sides unchanged. Both list the main worktree first with each entry's checked-out branch and path.

New seams. GitWorktreeLayout does libgit2-free path reasoning about git's on-disk worktree layout (commondir pointers, .git-as-a-file pointers, repository naming). IGitWorktreeEnumerator lists worktrees; WorktreePickerViewModel mirrors the existing RefPickerViewModel shape, and the context-bar switcher reuses it with a write-back that runs a context switch instead of editing a text box.

Things worth a careful look

Two libgit2 quirks drove the enumerator's design. Both were verified empirically against a real repo rather than assumed:

  • libgit2's worktree list omits the main worktree entirely, so it is synthesized by resolving the commondir pointer.
  • A pruned worktree (directory deleted out from under git) is still listed, but every property access throws NullReferenceException. Names therefore come from the <common>/worktrees/* administrative directories, which survive the deletion. Such worktrees are shown as unavailable rather than silently dropped.

The picker is offered on every local form, not just working-tree ones. I initially argued against this. Two worktrees share an object database and all shared refs, so main..feature resolves identically from either. But HEAD is per-worktree and the dialog advertises HEAD~3 as valid input, so the choice genuinely changes the answer for commit-ish forms too.

LocalRepoFormViewModelBase is an extraction, and it is the largest part of the diff. The five local New-diff forms each carried their own copy of the repo path, its canonicalization, and the deferred-error handling, three of them verbatim identical, and the worktree picker would have been a sixth copy. NewDiffDialog.xaml duplicated the repo-path Grid five times likewise and is now one RepoPathField control, which also retires the Browse handler's Tag="RepoPath" visual-tree walk in favour of a named element. The extraction is intended to be behavior-preserving; the full pre-existing suite passing unchanged is the main evidence for that, and the ordering of canonicalization vs Validate() vs side effects like TrySeedDefaultPartner is the part most worth a second pair of eyes.

The third commit fixes a latent trap found in review. RepositoryShape.CommonGitDirectory is documented as identical across every worktree of a repository, which is what makes "same repo, different checkout" recognizable. It was not: libgit2 reports a main worktree's git dir with a trailing separator while the commondir pointer resolves without one. Nothing consumed the field yet, so it was a trap for the first caller rather than a live bug.

Not included

True cross-worktree diffing (worktree A's files against worktree B's) is tracked separately in #23. It needs a model change, since DiffSide.WorkingTree is payload-free and ParsedCommandLine carries a single repo path.

Review findings addressed

Several findings arrived in review bodies rather than as inline comments. GitHub has no resolve state for those, so they stay visible at the top of the conversation and read as open forever. Tracking them here, since there is nowhere else to mark them done.

Finding Fixed in
ResolveCommonDirectory returned an unnormalized path from its catch block a36c3a2
WorktreePicker comment claimed EnsureLoadedAsync swallows failures a36c3a2
Picker could stay empty after a mid-flight repo-path change a1844b5
No service-level coverage for worktree label capture a1844b5
HasAlternativeWorktrees mis-reported a bare hub with one linked worktree a1844b5
Empty-state message rendered alongside "Loading..." a1844b5
StackPanel denied the popup's ScrollViewer a viewport a1844b5
Synchronous filesystem probe ran on the WPF dispatcher 51e83ee
Stash form could leave a newly selected worktree permanently unloaded 96ca109

All inline review threads are replied to and resolved.

Verification

  • dotnet build -c Release clean, 0 warnings.
  • dotnet test: 1644 passed, 1 skipped. The 1608 pre-existing tests pass unchanged.
  • Smoke-tested the built exe against a linked worktree to confirm the new XAML loads.
  • The three concurrency tests were re-run repeatedly to confirm determinism, after one was found racy and rewritten.

Session

  • AI-Local-Session: 80032277-bc2c-4613-a0d3-72ad20d3621b
  • AI-Cloud-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64

geevensingh and others added 3 commits August 31, 2026 17:14
DiffViewer already opened linked worktrees correctly, but nothing on
screen distinguished one from another. Recent-context rows were titled
from the path leaf, which for a worktree is usually the branch name and
never names the repository, so two worktrees of one repo rendered
near-identically.

Adds a libgit2-free GitWorktreeLayout helper that resolves the commondir
pointer, plus IGitWorktreeEnumerator for listing every worktree attached
to a repository. Two details are non-obvious: libgit2 omits the main
worktree from its worktree list, so it is synthesized from commondir;
and a pruned worktree is still listed but throws on every property
access, so names come from the administrative directory instead.

RepositoryShape now carries CommonGitDirectory and WorktreeName, and
recent rows persist the repository and worktree names captured at launch
time. Rows written by earlier versions load with null labels and fall
back to the old path-leaf rendering, healing on next launch.

AI-Local-Session: 80032277-bc2c-4613-a0d3-72ad20d3621b
AI-Cloud-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64
Pointing a diff at a different worktree meant remembering where
`git worktree add` put it and typing the path. Both the repo-path row in
the New diff dialog and the context bar now offer a worktree list: main
first, each entry showing its checked-out branch and path, with pruned
worktrees listed as unavailable rather than silently dropped.

The context-bar switcher reuses WorktreePickerViewModel with a write-back
that runs an in-place context switch instead of editing a text box,
carrying both diff sides over unchanged.

The picker is offered on every local form, not just working-tree ones.
Two worktrees share an object database and all shared refs, so
`main..feature` resolves identically from either - but HEAD is
per-worktree, and the dialog advertises HEAD~3 as valid input, so the
choice changes the answer for commit-ish forms too.

Extracts LocalRepoFormViewModelBase along the way. The five local forms
each carried their own copy of the repo path, its canonicalization, and
the deferred-error handling - three of them verbatim identical - and the
worktree picker would have been a sixth copy of the same pattern. The
repo-path row in NewDiffDialog.xaml was duplicated five times likewise,
and is now one RepoPathField control, which also retires the Browse
handler's Tag="RepoPath" visual-tree walk in favour of a named element.

AI-Local-Session: 80032277-bc2c-4613-a0d3-72ad20d3621b
AI-Cloud-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64
CommonGitDirectory is documented as identical across every worktree of a
repository, which is what makes "same repo, different checkout"
recognizable. It wasn't: libgit2 reports a main worktree's git directory
with a trailing separator, while the commondir pointer resolves through
Path.GetFullPath without one, so the two forms compared unequal.

No consumer relied on it yet, so this is a trap rather than a live bug.
The repository-service test now compares the raw strings instead of
normalizing both sides, which is what let the gap through.

AI-Local-Session: 80032277-bc2c-4613-a0d3-72ad20d3621b
AI-Cloud-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are a couple of correctness/documentation issues in new worktree-related helpers that should be addressed before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds first-class Git worktree awareness to DiffViewer’s UI by persisting stable worktree/repository labels for recents, appending worktree names to the window title, and enabling switching the current diff or new-diff dialog to a different checkout via a shared worktree picker control and view-model.

Changes:

  • Persist repository/worktree display labels into recents and surface them in the recents dropdown and window title
  • Add a reusable Worktree picker popup and integrate it into the New Diff dialog and the context bar
  • Extract local-repo-path handling into LocalRepoFormViewModelBase and introduce a LibGit2-based worktree enumerator plus test coverage
File summaries
File Description
DiffViewer/Views/WorktreePicker.xaml.cs Adds code-behind wiring for the worktree picker popup
DiffViewer/Views/WorktreePicker.xaml Defines the worktree picker popup UI and row styling
DiffViewer/Views/RepoPathField.xaml.cs Adds browse-folder behavior for the shared repo-path field control
DiffViewer/Views/RepoPathField.xaml Introduces reusable repo-path field with Browse and Worktree toggle
DiffViewer/Views/RecentsBarView.xaml Adds a context-bar Worktree switch toggle and embeds WorktreePicker
DiffViewer/Views/NewDiffDialog.xaml.cs Removes repo-path browse logic now handled by RepoPathField
DiffViewer/Views/NewDiffDialog.xaml Replaces repeated repo-path grids with the shared RepoPathField control
DiffViewer/ViewModels/WorktreePickerViewModel.cs Adds VM to enumerate/pick worktrees and write back selected path
DiffViewer/ViewModels/WorkingTreeVsHeadFormViewModel.cs Moves to LocalRepoFormViewModelBase for shared repo-path + picker support
DiffViewer/ViewModels/WorkingTreeVsCommitFormViewModel.cs Moves to LocalRepoFormViewModelBase and re-points ref picker on repo resolution
DiffViewer/ViewModels/ViewStashFormViewModel.cs Moves to LocalRepoFormViewModelBase and keeps stash enumeration behavior
DiffViewer/ViewModels/RecentContextsViewModel.cs Adds worktree switcher integration via WorktreePickerViewModel
DiffViewer/ViewModels/NewDiffDialogViewModel.cs Wires IGitWorktreeEnumerator into form dependencies
DiffViewer/ViewModels/MainViewModel.cs Builds window title with optional worktree name; wires worktree switching
DiffViewer/ViewModels/LocalRepoFormViewModelBase.cs Extracts local repo-path canonicalization, validation deferral, and worktree picker
DiffViewer/ViewModels/CommitVsCommitFormViewModel.cs Moves to LocalRepoFormViewModelBase and syncs both ref pickers on repo resolution
DiffViewer/ViewModels/BranchVsMergeBaseFormViewModel.cs Moves to LocalRepoFormViewModelBase and keeps merge-base seeding behavior
DiffViewer/Utility/GitWorktreeLayout.cs Adds libgit2-free worktree layout reasoning and label derivation
DiffViewer/Services/RepositoryService.cs Extends RepositoryShape with common-git-dir + worktree name data
DiffViewer/Services/RecentsJsonSerializer.cs Serializes/deserializes repository/worktree label metadata for recents
DiffViewer/Services/RecentContextsService.cs Captures worktree labels at record time via GitWorktreeLayout
DiffViewer/Services/NewDiffDialogHost.cs Supplies worktree enumerator to the New Diff dialog VM
DiffViewer/Services/LibGit2GitWorktreeEnumerator.cs Implements worktree enumeration with libgit2 quirks handled
DiffViewer/Services/IGitWorktreeEnumerator.cs Introduces worktree enumeration interface and WorktreeEntry model
DiffViewer/Services/IDiffModeProvider.cs Adds worktree enumerator to FormDependencies
DiffViewer/Models/RepositoryShape.cs Adds CommonGitDirectory and WorktreeName and computed IsLinkedWorktree
DiffViewer/Models/RecentLaunchContext.cs Adds repository/worktree label fields to recent context rows
DiffViewer/CompositionRoot.cs Wires worktree enumerator into MainViewModel construction
DiffViewer/AppServices.cs Adds optional WorktreeEnumerator to app service bundle
DiffViewer/App.xaml.cs Instantiates and wires the libgit2 worktree enumerator into services
DiffViewer.Tests/ViewModels/WorktreePickerViewModelTests.cs Adds unit tests for worktree picker VM behavior
DiffViewer.Tests/ViewModels/ViewStashFormViewModelTests.cs Updates dependencies to include a stub worktree enumerator
DiffViewer.Tests/ViewModels/NewDiffFormViewModelTests.cs Updates dependencies to include a stub worktree enumerator
DiffViewer.Tests/ViewModels/NewDiffDialogViewModelTests.cs Updates VM construction for required worktree enumerator
DiffViewer.Tests/ViewModels/MainViewModelWindowTitleTests.cs Adds tests for window title formatting with and without worktree name
DiffViewer.Tests/ViewModels/MainViewModelKeyboardShortcutTests.cs Updates RepositoryShape construction for new required fields
DiffViewer.Tests/ViewModels/MainViewModelContextMenuTests.cs Updates RepositoryShape construction for new required fields
DiffViewer.Tests/ViewModels/MainViewModelCommitMetadataTests.cs Updates RepositoryShape construction for new required fields
DiffViewer.Tests/ViewModels/DiffPaneViewModelTests.cs Updates RepositoryShape construction for new required fields
DiffViewer.Tests/ViewModels/BranchVsMergeBaseFormViewModelTests.cs Updates dependencies to include a stub worktree enumerator
DiffViewer.Tests/Utility/GitWorktreeLayoutTests.cs Adds tests for pure worktree layout reasoning and label derivation
DiffViewer.Tests/StubWorktreeEnumerator.cs Adds test double for worktree enumeration
DiffViewer.Tests/Services/TempRepo.cs Adds helpers for creating and cleaning up real linked worktrees in tests
DiffViewer.Tests/Services/RepositoryServiceTests.cs Adds coverage for shape worktree name and common-git-dir invariants
DiffViewer.Tests/Services/PreDiffPassTests.cs Updates RepositoryShape construction for new required fields
DiffViewer.Tests/Services/NewDiffDialogHostTests.cs Updates host construction for required worktree enumerator
DiffViewer.Tests/Services/LibGit2GitWorktreeEnumeratorTests.cs Adds behavioral tests for main and prunable worktrees enumeration
DiffViewer.Tests/RecentContexts/RecentsJsonSerializerTests.cs Adds tests for label round-trip and backward compatibility
DiffViewer.Tests/RecentContexts/RecentContextsViewModelWorktreeTests.cs Adds tests for context-bar worktree switching behavior
DiffViewer.Tests/RecentContexts/RecentContextItemTests.cs Adds tests for new title formatting with repository/worktree labels
CHANGELOG.md Documents worktree legibility and switching under Unreleased
Review details
  • Files reviewed: 51/51 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread DiffViewer/Utility/GitWorktreeLayout.cs
Comment thread DiffViewer/Views/WorktreePicker.xaml.cs Outdated
ResolveCommonDirectory normalized every success path but returned the
raw string from its catch block, which reintroduces exactly the
trailing-separator mismatch the method exists to eliminate: a probing
failure would silently make two worktrees of one repository compare as
unrelated. Normalize is now best-effort itself, so the failure path can
use it without risking a throw from a helper documented to degrade.

Also corrects the WorktreePicker comment that claimed EnsureLoadedAsync
swallows enumerator failures. It does not - it has try/finally, not
try/catch, and relies on the enumerator returning an empty list. The
view-level handler is the actual safety net, being the async void
boundary where an unexpected exception would tear the dialog down.

Both found by Copilot code review on PR #24.

AI-Local-Session: 80032277-bc2c-4613-a0d3-72ad20d3621b
AI-Cloud-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Worktree picker loading, bare-repository detection, and popup scrolling issues remain, and label persistence lacks service-level coverage.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (3)

Previously missed (3) — in code that hasn't changed since the last review.

DiffViewer/ViewModels/WorktreePickerViewModel.cs:84

  • This assumes every enumeration contains a main entry, but the enumerator intentionally omits it for a bare hub. When a bare repository has exactly one linked worktree, that row is an available alternative yet this returns false and the popup reports that no other worktrees exist. Base the result on whether any entry is not current instead of the total count.
    DiffViewer/Views/WorktreePicker.xaml:60
  • A vertical StackPanel measures this ScrollViewer with unbounded height, so the border's MaxHeight does not establish a viewport and the scrollbar will not make lower rows reachable when many worktrees exist. Use a constrained Grid row for the list or give the ScrollViewer an explicit maximum height.
    DiffViewer/Views/WorktreePicker.xaml:104
  • HasAlternativeWorktrees is false before the asynchronous load finishes, so this empty-state message is visible at the same time as “Loading…”. Gate it on IsLoaded as well, so users are not told that no alternatives exist before enumeration has produced a result.
  • Files reviewed: 51/51 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread DiffViewer/ViewModels/WorktreePickerViewModel.cs Outdated
Comment thread DiffViewer/Services/RecentContextsService.cs Outdated
Five issues from the second Copilot review pass, all confirmed against
the code:

EnsureLoadedAsync dropped a stale result and returned. Because the
IsLoading guard turns away any caller arriving mid-load, a repo-path
change during enumeration could leave an already-open popup empty with
nothing left to trigger a reload. It now loops and re-enumerates the new
path, which is what the turned-away caller was relying on.

HasAlternativeWorktrees counted rows, but the enumerator deliberately
omits the main worktree for a bare hub, so a single linked worktree read
as "no alternatives". It now asks whether any entry is not the current
one. A listed-but-unreachable pruned worktree still counts, so the popup
never claims there is nowhere to go while visibly listing somewhere.

The empty-state message was bound to the negation of that flag, so it
rendered alongside "Loading..." asserting an answer enumeration had not
produced yet. Added ShowsEmptyState, gated on IsLoaded.

The popup body was a StackPanel, which measures children with unbounded
height - the ScrollViewer never established a viewport, so the Border's
MaxHeight clipped lower rows out of reach entirely. Now a Grid with a
star-sized list row, matching RefPicker.

RecordLaunchAsync captures the worktree labels but had no service-level
coverage; the existing tests use nonexistent paths. Added tests over a
synthetic on-disk layout covering main and linked worktrees, disk
round-trip, healing of a legacy unlabeled row, and the non-repository
path.

AI-Local-Session: 80032277-bc2c-4613-a0d3-72ad20d3621b
AI-Cloud-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Synchronous filesystem probing can block the WPF UI thread during recent-context recording.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

DiffViewer/Services/RecentContextsService.cs:92

  • This label lookup performs synchronous filesystem probes before RecordLaunchAsync has necessarily yielded. In the normal uncontended case, the coordinator calls this on the UI thread and WaitAsync completes synchronously, so an offline or slow network repository path can freeze the window during launch. Please move the probe to a background task, consistent with the repository's rule that filesystem I/O must not block WPF's dispatcher.
  • Files reviewed: 52/52 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

RecordLaunchAsync captured worktree labels synchronously. The
coordinator awaits it from the UI thread during a context swap, and
_gate.WaitAsync completes synchronously when uncontended, so everything
up to the first real suspension ran on the dispatcher - including
GitWorktreeLayout.Describe, which stats the repo path. A repo on an
offline or slow network share would block that stat until SMB timed out
and freeze the window mid-launch, violating the rule in AGENTS.md
section 9 that filesystem IO must not block WPF's dispatcher.

The probe now runs through a runner that defaults to Task.Run, awaited
before the gate is taken - which both guarantees the method yields
before touching the file system and shortens the critical section. The
runner is an internal seam mirroring IRootScanRunner in LocalRepoLocator
so tests can observe which thread the probe lands on; two tests cover
that it runs off the caller and that no probing happens synchronously.

Found by Copilot code review on PR #24.

AI-Local-Session: 80032277-bc2c-4613-a0d3-72ad20d3621b
AI-Cloud-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64
@geevensingh

Copy link
Copy Markdown
Owner Author

Synchronous filesystem probing can block the WPF UI thread during recent-context recording.
This label lookup performs synchronous filesystem probes before RecordLaunchAsync has necessarily yielded.

Confirmed and fixed in 51e83ee. The analysis was exactly right, including the subtle part: _gate.WaitAsync returns an already-completed task when uncontended, so the await never suspends and everything up to the first real suspension stayed on the dispatcher. All three TryRecordAsync call sites in MainWindowCoordinator await with ConfigureAwait(true) from the UI thread during a context swap, so the exposure is real rather than theoretical — a repo on a dead UNC mount would have blocked Directory.Exists until SMB timed out, freezing the window mid-launch.

The probe now runs through a runner defaulting to Task.Run, awaited before the gate is taken. That both guarantees the method yields before touching the file system and shortens the critical section, since the probe only depends on the repo path and needs no lock.

The runner is an internal seam mirroring IRootScanRunner in LocalRepoLocator, which exists for the same reason. Two tests cover it: one asserting the probe lands on a different thread than the caller, one asserting nothing is probed synchronously before the method returns. Both fail if the call is ever inlined back.

Noting for the record that this arrived as a review-body finding with no inline thread, so there is nothing to resolve on this one — replying here instead so the fix is on the record.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Mid-flight stash enumeration can prevent the selected worktree’s stash list from ever loading.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

DiffViewer/ViewModels/ViewStashFormViewModel.cs:71

  • Switching the stash form to another worktree while its initial stash enumeration is still running can leave the new checkout permanently unloaded. OnRepoPathResolved starts a new enumeration, but EnumerateStashesAsync immediately returns while IsLoading is true; the old request then detects the changed path and drops its result, with nothing retrying the new path. Coalesce or loop stale loads as WorktreePickerViewModel.EnsureLoadedAsync does, and cover the mid-flight path-change sequence.
  • Files reviewed: 52/52 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Same defect the worktree picker had, in the other view-model that loads
on a repo-path change. EnumerateStashesAsync turned away any caller
arriving while IsLoading was true, then dropped its own result when the
path had moved - so nothing loaded the new checkout and the stash list
stayed empty until the user edited the path again.

It was latent before: changing the repo path meant typing. The worktree
picker makes it a single click, which is what makes it worth fixing
here rather than leaving as pre-existing.

Now loops on a stale result, mirroring
WorktreePickerViewModel.EnsureLoadedAsync. Covered by a test that
re-points the form from inside the enumerate runner and asserts both
paths were enumerated and the new checkout's stashes landed.

Also replaces Task.Yield with a TaskCompletionSource gate in
RecordLaunchAsync_YieldsBeforeProbingTheFileSystem. Task.Yield only
schedules the continuation, so whether the probe had run by the time the
assertion executed was a race; the test failed on its second run. The
gate makes it deterministic - the probe provably cannot have run.

Found by Copilot code review on PR #24.

AI-Local-Session: 80032277-bc2c-4613-a0d3-72ad20d3621b
AI-Cloud-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b6d4fbf2-3954-4138-a5fb-aa9eaecbff64
@geevensingh

Copy link
Copy Markdown
Owner Author

Mid-flight stash enumeration can prevent the selected worktree's stash list from ever loading.

Confirmed and fixed in 96ca109. This is the same defect you found in WorktreePickerViewModel, in the other view-model that loads on a repo-path change — EnumerateStashesAsync turned away any caller arriving while IsLoading was true, then dropped its own result once the path had moved, leaving nothing to load the new checkout.

Worth noting why it belongs in this PR rather than being waved off as pre-existing: the guard and the stale-drop both predate these changes, but reaching them used to require typing a new path mid-enumeration. The worktree picker this PR adds turns that into a single click, so the change is what makes the bug readily reachable.

Fixed the same way as the picker — loop on a stale result instead of discarding it — with a test that re-points the form from inside the enumerate runner and asserts both paths were enumerated and the new checkout's stashes landed.

Separately, while validating this I found that a test I added in the previous round, RecordLaunchAsync_YieldsBeforeProbingTheFileSystem, was itself racy: Task.Yield only schedules the continuation, so whether the probe had run by the assertion was a coin flip, and it failed on its second execution. Replaced with a TaskCompletionSource gate so the probe provably cannot have run at the point of the check. Re-ran the three concurrency tests three times to confirm they're stable.

This one also arrived as a review-body finding with no inline thread, so there's nothing to resolve — replying here to keep the fix on the record.

@geevensingh
geevensingh merged commit 516b97a into master Sep 8, 2026
1 check passed
geevensingh added a commit that referenced this pull request Sep 8, 2026
Two user-facing features since v1.9.0 (worktree switching in the New
diff dialog and context bar; worktree identification in recents and the
window title), so a minor bump under the post-1.0 rules in AGENTS.md 12.
Checked against the six breaking surfaces: no CLI argv change, recents
rows written by older versions still parse, install footprint untouched.

Also logs the stale stash-enumeration fix (96ca109) under Fixed. It
landed as a review follow-up on PR #24 without a changelog entry, but it
repairs a shipped feature - the View stash form going blank after the
repository path moved mid-load - so it belongs in the release notes.

AI-Local-Session: c7aecf5c-ca18-4b85-94db-c1325614b602
AI-Cloud-Session: 6f972cbf-b409-428f-8d6a-9f764839e4b0
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants