Skip to content

Commit aefd5bb

Browse files
authored
Measure a worktree against where it lands, not the repo default (SPEC-51) (#160)
* Measure a worktree against where it lands, not the repo default (SPEC-51) A worktree stacked on another worktree's branch reported its parent's work as its own: `repo_service` handed the repo default to `diffStat`/`commitsAhead` for every worktree, and the base the user picked at creation time was passed once to `git worktree add` and then discarded. A child with 3 lines on top of a 20-line parent showed +23. Model the *target* — where the work lands — instead of the base. One field feeds the diff (`target...HEAD`, i.e. what a PR would contain), the ahead-count fallback, `gh pr create --base`, and the branch a wrap-up fast-forwards. Being three-dot, git finds the merge base live, so no fork point is stored and the diff self-heals once a parent lands. The contract: - resolveTargetBranch owns precedence: primary/detached → null, then an OPEN PR's baseRefName (which inherits GitHub's auto-retargeting for free), then the persisted choice, then the repo default. Default-last means upgrading moves nobody's numbers until they choose. - Renaming a branch repoints every worktree that lands in it. - Wrapping up hands its target down, recursively, so a stack that lands bottom-up collapses to where it actually landed. - A target that vanishes without a wrap-up falls back through the chain to the default and *says so*, until the user picks one explicitly. - A live PR's base is adopted into the persisted value, so closing or reopening no longer reverts to a pre-PR value. DiffStat gains `targetResolved`, because the failure mode is not a zero: when the committed leg fails, working-tree files still count, so an unresolvable target yields a plausible *small* number. Clients suppress the pill rather than publish a partial count. The target is create-time config, so it spends no first-glance space. Three disclosures share one picker: worktree actions (canonical — the only per-worktree menu, and the only one that exists without a session), the `Ship it` caret menu, and a `branch ≫ target` line in the detail sheet header. "Lands in" is enabled where Rename is blocked, because retargeting an open PR is a first-class operation. `base` → `target` throughout, with two documented exceptions: `baseRefName` (GitHub's own field) and one-release wire aliases on worktree.create/wrapUp. Three bugs found only by driving the real app: the picker was dead in a repo with no remote (the on-remote rule is vacuous without one), a merged PR kept overriding the user's choice, and the retarget announcement stole the composer strip's headline from an actionable fact. Server 1386 tests, app 333 across the touched suites. * Harden the lands-in feature against the review's edge cases (SPEC-51) Address the failing CI guard and the ten open review threads on #160. Failing check: - lands_in_picker: post the retarget failure to the StatusCenter (ref.status.failure) instead of a raw showSnackBar, so it lands on the Activity record — satisfies the no-snackbar guard test. Server: - git.diffStat: zero the counts when targetResolved is false, so a consumer that forgets the flag degrades to "nothing", not a plausible partial reading. - git.closestAncestorBranch: one parallel `rev-list --left-right --count` per candidate instead of two serial calls each (bounded), killing the O(2N) serial subprocess fan-out on picker open. - git.listRemoteBranchNames: scope to refs/remotes/origin — a branch that exists only on another remote is not a valid `gh` PR base. - repo_service.repairVanishedTargets: only touch THIS repo's worktree paths (was corrupting other repos' persisted targets from the global store), and treat origin branches as live so a just-adopted remote-only PR base is not clobbered back to the default. Extracted the pure core, repointVanishedTargets. - repo_service.listRepos: wire pruneTargets against the union of live worktree paths, guarded against a transient git failure (a git repo reporting zero worktrees aborts the sweep). pruneTargets was dead code. - worktree-target-store.saveTargets/putTarget: return whether the write landed; manager.setWorktreeTarget now throws on a failed persist instead of acking a success the next snapshot contradicts. - manager.removeWorktree: clear the target under the canonical (resolved) path. - ws/commands/worktree: fix the misleading setTarget ordering comment, collapse the nested target/base ternaries, drop the "" default; alias the shadowed targetCandidates import in the manager. App: - pr_signals: gate the Ship it / Create PR CTA on targetResolved — an unresolvable target has nowhere to land and would open the PR against the wrong base. - pr_detail._open: open the re-derived live PR url, not the stale widget field (was throwing a null assertion when the live PR appeared). - repo_chips onRun / worktree_actions: re-derive the live worktree at invocation time so a remedy or picker acts on today's target, not the value captured when the sheet opened. * Close the second review round on the lands-in feature (SPEC-51) Fix the failing status-lifetime guard and the six open review threads on #160. Failing check + thread 6 (same root cause): - lands_in_picker: hoist `final status = ref.status` above the picker await, so a widget disposed while the picker is open can't throw on a defunct ref and lose the target change. Reworded the comment to drop the word "await", which was tripping the guard's own textual scan. App: - pr_detail: when the snapshot knows the worktree but its PR is now null, use that null (no PR) instead of falling back to the stale open-time `this.pr` — the old `??` resurrected a closed PR's title and GitHub link (a null-assert waiting to happen). Regression test added. - worktree_actions: wrap the bottom-sheet body in a Consumer so `ref.watch` subscribes inside the sheet's own element — the outer WidgetRef rebuilt the caller, never the open sheet, so its target/guards froze at open-time. Server: - repo_service.listRepos: revert the pruneTargets sweep. It was a write in a read path that could delete real targets on a transient `isGitRepo`/worktree- enumeration failure and race a concurrent create. Stale entries are already harmless (removeWorktree clears, createWorktree overwrites a reused path, a vanished target surfaces as targetResolved:false), so the sweep bought nothing worth that risk. Removed collectLivePathsForPrune + its tests. - manager._handDownTarget: include `origin` branches in the live set (like repairVanishedTargets), so a wrap-up whose fetch didn't land doesn't drag every child worktree onto the repo default instead of the branch its PR targets. Regression test drives it through a remote-only landing branch. * Scope target hand-down and rename to the repo that triggered them (SPEC-51) Third review round (macroscope + coderabbit both High/Major): the worktree target store is GLOBAL across every project, but two writers matched entries by branch name alone. Branch names are not unique across repos (main, dev, develop, a shared feature name), so: - manager._handDownTarget wrapped up `develop` in one repo and silently retargeted every `develop`-bound worktree in *other* repos — moving their diff and future PR base, and stamping a bogus retargetedFrom announcement. - worktree-target-store.renameTargetBranch (via renameWorktreeBranch) rewrote a same-named target in unrelated repos on any branch rename. Both now scope to the triggering repo's own worktree paths (`trees`, already loaded). renameTargetBranch takes an optional `scope` set; _handDownTarget keeps its cheap global pre-check but filters `affected` to this repo. Regression tests seed a foreign-repo entry and assert it is left untouched (verified they fail without the scoping). * Clear the third review round in full — real findings, nits, and off-diff (SPEC-51) Fixes everything valid across macroscope + coderabbit + open-code-review, plus the pre-existing (off-diff) session-lifecycle bugs macroscope surfaced. Staleness pattern (the round-2 fix, completed everywhere): - session_pr_chip, pr_bar, desktop_sidebar: re-derive the live worktree/status/pr at invocation time instead of the value captured when the sheet/menu opened. - All re-derivation paths (repo_chips too) now guard `context.mounted` before touching `ref`, so a row removed by a snapshot mid-sheet can't throw on a defunct ref (and can't resurrect a stale PR: pr uses at.worktree.pr when known). Target-store correctness: - repairVanishedTargets: liveness is now local refs ∪ OPEN-PR bases, not every refs/remotes/origin/* ref — a stale ref left after a merged branch is auto-deleted no longer blocks the repair, while a remote-only PR base is still protected. adoptLivePrTargets now skips `stale` PRs (no overwriting a fresh user target with unverified data during a GitHub outage). - _handDownTarget: liveness is local refs ∪ the explicit landedIn, for the same reason (drops the all-remotes dependency). - repointVanishedTargets: never persists a self-target (mutual-stack edge). - Persistence failures are no longer swallowed: saveTargets’ result propagates through clearTarget/renameTargetBranch/pruneTargets; createWorktree, removeWorktree and _handDownTarget log when a best-effort persist fails. - target_candidates: previews only SELECTABLE candidates (skip off-remote), so a disabled local-only branch can't consume a preview slot. Off-diff (pre-existing) session lifecycle: - createSession kills a half-started adapter if start() rejects (was leaking a live child, unlike the reattach path). - toSessionListItem no longer reports a closed session as `attached`. - attachPiSession reopens + relives a closed session instead of returning its DetachedAdapter (which launched no process). Nits: reattached two orphaned JSDoc blocks; hasStale→hasCandidates; cached the double lastKnown lookup; lazy ListView.builder in the picker; ellipsis on the menu's target-branch text; TODO(SPEC-51) markers on the baseBranch shims; doc + naming-collision comments; import grouping; mockup `font: … inherit` → var(--sans). Tests: cross-repo isolation already covered; added hasAnyRemote, the worktree group, asymmetric hasPreview, an actual open-PR "gone" signal, the retarget command assertion, a settle() deadline, and updated the repoint unit tests for the new liveness + self-target guard. Server 1398 pass; flutter analyze clean. * Fix post-merge HIGH threads: race safety, session lifecycle, hasOriginRemote * Settle the target-liveness trade-off, coalesce revivals, honour the override Second post-merge review round (macroscope HIGH ×2 + Medium, CodeRabbit Major). - repo_service.repairVanishedTargets: `origin` refs are live again, and the trade-off is now documented in place so it stops flip-flopping. Excluding them silently REDIRECTS a worktree whose target lives only on the remote (open PR into a remote-only `release` → PR closes → target rewritten to the default, moving every future diff and PR base). Including them only DELAYS a repair until the next `fetch --prune`, which surfaces as the honest `targetResolved: false`. A delayed repair beats an unrecoverable redirect. Regression test drives a remote-only target (verified it fails without the fix). - manager.attachPiSession: reviving a closed session now goes through the same `attachInFlight` dedupe as a fresh attach. `reopenSession` clears `closed` before `reattachSession` finishes `start()`, so an uncoalesced second caller saw `closed === false`, returned early, and could send to an uninitialised adapter. - target_candidates: takes the stored `defaultBranchOverride` and resolves via `resolveDefaultBranch`, mirroring `repoSnapshot`. The picker was labelling and ranking git's own answer as `default` while every diff and new worktree used the override — the picker disagreeing with the app about what "default" means. - lands_in_picker: `candidatesFuture.ignore()` before the modal opens. The request is in flight before the route's first build and the user can dismiss before it lands, so a rejection reached no listener and escaped to the zone handler. Same pattern as `_NewWorktreeDialogState._loadPrs`; the FutureBuilder still renders the error state. Server 2143 pass; flutter analyze clean. * Make a refused target-rename write distinguishable from a no-op `renameTargetBranch` returned 0 both for "nothing pointed at the old name" (a success) and for "the store is not writable" (a silent divergence where every dependent worktree keeps aiming at a branch that no longer exists). It now returns `number | null`, and `renameWorktreeBranch` logs the refusal — git has already renamed the branch by then, so this cannot fail the operation, but it must not pass unrecorded. Adds the two test gaps CodeRabbit flagged: `scope` coverage (a rename in one repo leaving another repo's same-named target alone) and the null-vs-0 contract. * One live-PR predicate, a pinned test store, and a spec correction - repo_service: extract `isLivePr` (a type guard, so it narrows) — two sites encoded "exists && OPEN && not stale" independently, and a change to one would silently diverge from the other. - target_rules fixture: pin MAKIT_WORKTREE_TARGETS_FILE. `worktreeTargetsFile()` prefers it over MAKIT_HOME, so a value inherited from another suite would point these tests at a shared store. - SPEC-51: the wire aliases read `||`, not `??` (an empty string must fall through), and the wrapUp fallback is `defaultBranchFor()` post-merge. * Handle the origin/-qualified default branch on both read paths `resolveDefaultBranch` deliberately returns a remote-only default QUALIFIED (`origin/release`) because git cannot resolve a bare name against `refs/remotes/origin/`. Two consumers assumed the bare form: - repo_service.repairVanishedTargets built `live` from `listRemoteBranchNames`, which STRIPS the prefix — so `live` held `release` while `defaultBranch` was `origin/release`. `resolveThroughChain` rejected a perfectly live default as "gone" and skipped the repair, leaving the worktree on a broken target. It now records both spellings. - target_candidates built the candidate list from local branches only, so a remote-only default was omitted entirely and NO candidate received the `default` group — the picker could not offer the branch every diff and new worktree measures against, recreating the exact disagreement the override-threading was meant to fix. It is now offered, and marked `onRemote` (it is on the remote by definition). Coverage note: the picker test fails without its fix (verified). The `repointVanishedTargets` unit tests pin the CONTRACT (the core honours a qualified default; a default absent from `live` is never invented) but pass `live` in by hand, so they do not by themselves cover the caller's `live` construction — the existing "a target that still exists on origin is NOT repaired away" test is what exercises that path.
1 parent e69d750 commit aefd5bb

50 files changed

Lines changed: 7673 additions & 268 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.pnpm-store/v11/index.db

-8 KB
Binary file not shown.

.pnpm-store/v11/projects/739d8fce9f21779d2680f9e8d181f4ee

Lines changed: 0 additions & 1 deletion
This file was deleted.

app/lib/desktop/chat/desktop_sidebar.dart

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import '../../ui/docs/docs_popover.dart';
1515
import '../../ui/docs/doc_preview.dart';
1616
import '../../ui/ports/ports_popover.dart';
1717
import '../../ui/ports/session_ports_glyph.dart';
18+
import '../../ui/widgets/lands_in_picker.dart';
1819
import '../../ui/widgets/pr_state_style.dart';
1920
import '../../ui/project/folder_browser.dart';
2021
import '../../ui/widgets/connection_chip.dart';
@@ -647,6 +648,27 @@ class _WorktreeGroupState extends ConsumerState<_WorktreeGroup> {
647648
switch (action) {
648649
case 'rename':
649650
_renameBranch();
651+
case 'landsIn':
652+
// NOT `sheet: true`: desktop wants the
653+
// dialog form of the picker, not a bottom
654+
// sheet. It persists the choice itself.
655+
// Re-derive the live worktree: a snapshot
656+
// may have retargeted or moved it while the
657+
// menu was open, so act on today's value.
658+
final live =
659+
ref
660+
.read(reposProvider)
661+
.locateWorktree(worktree.path)
662+
?.worktree ??
663+
worktree;
664+
unawaited(
665+
showLandsInPicker(
666+
context,
667+
ref,
668+
projectId: repo.id,
669+
worktree: live,
670+
),
671+
);
650672
case 'delete':
651673
_deleteWorktree();
652674
case 'ports':
@@ -665,7 +687,9 @@ class _WorktreeGroupState extends ConsumerState<_WorktreeGroup> {
665687
}
666688
},
667689
)
668-
else if (worktree.hasChanges)
690+
// See worktree_row.dart: suppress rather than
691+
// publish a partial count.
692+
else if (worktree.showsDiff)
669693
DiffChip(
670694
insertions: worktree.insertions,
671695
deletions: worktree.deletions,
@@ -836,8 +860,12 @@ class _WorktreeGroupState extends ConsumerState<_WorktreeGroup> {
836860
/// The worktree row's hover overflow menu (triple dots that replace the diff
837861
/// pill on hover). Reports the chosen action up to [_WorktreeGroupState], which
838862
/// owns the dialogs and store calls with a context/ref that outlives the menu.
839-
/// "Rename branch" and "Delete worktree" are disabled for the primary worktree;
840-
/// "Rename branch" is also disabled for detached worktrees and open PRs.
863+
/// "Rename branch", "Lands in" and "Delete worktree" are disabled for the
864+
/// primary worktree and for detached worktrees; "Rename branch" is additionally
865+
/// disabled for an open PR, but "Lands in" is NOT (retargeting an open PR is a
866+
/// first-class operation). Because this menu replaces the diff pill on hover,
867+
/// "Lands in" prints the current target inline — the pill is not visible to
868+
/// glance at while the menu is open.
841869
class _WorktreeMenuButton extends ConsumerWidget {
842870
const _WorktreeMenuButton({
843871
required this.worktree,
@@ -856,6 +884,11 @@ class _WorktreeMenuButton extends ConsumerWidget {
856884
final isPrimary = worktree.isPrimary;
857885
final isDetached = worktree.branch == null;
858886
final canRename = !_hasOpenPr && !isPrimary && !isDetached;
887+
// Retargeting shares rename's structural guards (no primary, no detached)
888+
// but pointedly NOT its open-PR block: renaming orphans a PR's head, while
889+
// retargeting an open PR is a first-class operation (`gh pr edit --base`).
890+
// So an open PR leaves this enabled while it disables Rename — deliberate.
891+
final canRetarget = !isPrimary && !isDetached;
859892
// The count is a glance at what this branch is serving; the item routes to
860893
// the global Ports screen either way (D8), so it shows even at zero.
861894
final portCount = ref.watch(portsForWorktreeProvider(worktree.path)).length;
@@ -891,6 +924,50 @@ class _WorktreeMenuButton extends ConsumerWidget {
891924
),
892925
),
893926
const PopupMenuDivider(),
927+
PopupMenuItem(
928+
value: 'landsIn',
929+
enabled: canRetarget,
930+
height: 36,
931+
child: Tooltip(
932+
message: isPrimary
933+
? 'This is where branches land, not one that lands'
934+
: isDetached
935+
? 'This worktree has no branch to land'
936+
: '',
937+
// Bare Text + a trailing value, no leading icon — matching the
938+
// other items in this menu. The `⋯` button REPLACES the diff pill
939+
// on hover, so the user cannot glance back at the pill for the
940+
// current target while this menu is open; the item prints its own
941+
// current value on the right instead.
942+
child: Row(
943+
children: [
944+
Text(
945+
'Lands in',
946+
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
947+
color: canRetarget ? null : Theme.of(context).disabledColor,
948+
),
949+
),
950+
const Spacer(),
951+
if (worktree.targetBranch != null) ...[
952+
const SizedBox(width: kSpace12),
953+
Flexible(
954+
child: Text(
955+
worktree.targetBranch!,
956+
overflow: TextOverflow.ellipsis,
957+
style: Theme.of(context).textTheme.labelSmall?.mono
958+
.copyWith(
959+
color: canRetarget
960+
? Theme.of(context).colorScheme.outline
961+
: Theme.of(context).disabledColor,
962+
),
963+
),
964+
),
965+
],
966+
],
967+
),
968+
),
969+
),
970+
const PopupMenuDivider(),
894971
PopupMenuItem(
895972
value: 'delete',
896973
enabled: !isPrimary,

app/lib/desktop/chat/new_worktree_dialog.dart

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> {
6262
String? _projectId;
6363
_WorktreeFrom _source = _WorktreeFrom.newBranch;
6464
String? _existingWorktreePath;
65-
String? _baseBranch;
65+
String? _targetBranch;
6666
int? _prNumber;
6767
Future<List<OpenPr>>? _prsFuture;
6868

@@ -81,7 +81,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> {
8181
widget.initialProjectId ??
8282
_activeGroupRepo() ??
8383
(repos.isNotEmpty ? repos.first.id : null);
84-
_baseBranch = _defaultBranchFor(_projectId);
84+
_targetBranch = _defaultBranchFor(_projectId);
8585
}
8686

8787
@override
@@ -115,18 +115,18 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> {
115115
return null;
116116
}
117117

118-
/// The base branch actually used: [_baseBranch] when it is a live option,
118+
/// The target branch actually used: [_targetBranch] when it is a live option,
119119
/// else the first option (what [_newBranchPanel] displays).
120-
String? _effectiveBaseBranch(String? projectId) {
120+
String? _effectiveTargetBranch(String? projectId) {
121121
RepoInfo? repo;
122122
for (final r in ref.read(reposProvider).repos) {
123123
if (r.id == projectId) repo = r;
124124
}
125125
final options = repo == null
126126
? const <String>[]
127127
: branchOptionsForRepo(repo);
128-
if (options.isEmpty) return _baseBranch;
129-
return options.contains(_baseBranch) ? _baseBranch : options.first;
128+
if (options.isEmpty) return _targetBranch;
129+
return options.contains(_targetBranch) ? _targetBranch : options.first;
130130
}
131131

132132
void _close() {
@@ -148,7 +148,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> {
148148
if (projectId == null || projectId == _projectId) return;
149149
setState(() {
150150
_projectId = projectId;
151-
_baseBranch = _defaultBranchFor(projectId);
151+
_targetBranch = _defaultBranchFor(projectId);
152152
// A different repo has a different PR list; drop the cached future so the
153153
// panel refetches when From PR is shown again.
154154
_prsFuture = null;
@@ -200,7 +200,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> {
200200
final name = _branchNameCtrl.text.trim();
201201
created = await store.createWorktree(
202202
projectId,
203-
baseBranch: _effectiveBaseBranch(projectId),
203+
targetBranch: _effectiveTargetBranch(projectId),
204204
branchName: name.isEmpty ? null : name,
205205
);
206206
case _WorktreeFrom.fromPr:
@@ -499,8 +499,8 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> {
499499
)
500500
: DropdownButtonFormField<String>(
501501
key: ValueKey('wt-branch-$_projectId'),
502-
initialValue: options.contains(_baseBranch)
503-
? _baseBranch
502+
initialValue: options.contains(_targetBranch)
503+
? _targetBranch
504504
: options.first,
505505
isExpanded: true,
506506
items: [
@@ -512,7 +512,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> {
512512
],
513513
onChanged: _creating
514514
? null
515-
: (v) => setState(() => _baseBranch = v),
515+
: (v) => setState(() => _targetBranch = v),
516516
);
517517
return Column(
518518
crossAxisAlignment: CrossAxisAlignment.start,

app/lib/desktop/chat/pr_bar.dart

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:phosphoricons_flutter/phosphoricons_flutter.dart';
44

55
import '../../app/theme.dart';
66
import '../../store/models.dart';
7+
import '../../store/store.dart';
78
import '../../ui/widgets/icon_glyph.dart';
89
import '../../ui/widgets/pr_detail.dart';
910
import '../../ui/widgets/pr_signals.dart';
@@ -111,6 +112,10 @@ class PrComposerBar extends ConsumerWidget {
111112
const SizedBox(width: kSpace10),
112113
PrCtaButton(
113114
status: status,
115+
// Home 1: the caret menu's "This worktree" group needs an identity to
116+
// name; without one the group is simply absent.
117+
projectId: projectId,
118+
worktreePath: worktreePath,
114119
onRun: (remedy) => _run(context, ref, remedy),
115120
),
116121
],
@@ -121,22 +126,40 @@ class PrComposerBar extends ConsumerWidget {
121126
context,
122127
status: status,
123128
pr: pr,
129+
// Identity, so the sheet re-derives rather than freezing its facts.
130+
projectId: projectId,
131+
worktreePath: worktreePath,
124132
onRun: (remedy) => _run(context, ref, remedy),
125133
);
126134

127-
Future<void> _run(BuildContext context, WidgetRef ref, PrRemedy remedy) =>
128-
runPrRemedy(
129-
context,
130-
ref,
131-
remedy: remedy,
132-
status: status,
133-
pr: pr,
134-
projectId: projectId,
135-
worktreePath: worktreePath,
136-
branch: branch,
137-
uncommittedFiles: uncommittedFiles,
138-
onInsertPrompt: onInsertPrompt,
139-
);
135+
Future<void> _run(
136+
BuildContext context,
137+
WidgetRef ref,
138+
PrRemedy remedy,
139+
) async {
140+
// Re-derive from the snapshot at call time: the in-dialog "Lands in" picker
141+
// can change the PR base (and the derived facts) while the dialog is open, so
142+
// a remedy must act on today's target, not the build-time `status`/`pr`.
143+
// Guard `context.mounted` first — the bar can be torn down by a snapshot and
144+
// reading `ref` on a defunct element throws. Falls back to open-time values
145+
// when the row is gone from the snapshot.
146+
if (!context.mounted) return;
147+
final at = ref.read(reposProvider).locateWorktree(worktreePath);
148+
await runPrRemedy(
149+
context,
150+
ref,
151+
remedy: remedy,
152+
status: at == null ? status : prStatusFor(at),
153+
pr: at == null ? pr : at.worktree.pr,
154+
projectId: projectId,
155+
worktreePath: worktreePath,
156+
branch: at == null ? branch : at.worktree.branch,
157+
uncommittedFiles: at == null
158+
? uncommittedFiles
159+
: at.worktree.uncommittedFiles,
160+
onInsertPrompt: onInsertPrompt,
161+
);
162+
}
140163
}
141164

142165
/// The sentence: a status dot, the PR number (or branch), and the loud fact.
@@ -269,10 +292,21 @@ class _MoreLink extends StatelessWidget {
269292
/// * **agent prompt** — tonal fill in the fact's tone; inserts text,
270293
/// * **direct op** — solid fill; runs now (behind a confirm when destructive).
271294
class PrCtaButton extends ConsumerWidget {
272-
const PrCtaButton({super.key, required this.status, required this.onRun});
295+
const PrCtaButton({
296+
super.key,
297+
required this.status,
298+
required this.onRun,
299+
this.projectId,
300+
this.worktreePath,
301+
});
273302

274303
final PrStatus status;
275304

305+
/// Identity for the menu's "Lands in" entry (Home 1). Optional: a surface with
306+
/// no resolvable worktree just does not show the group.
307+
final String? projectId;
308+
final String? worktreePath;
309+
276310
/// Every action — prompt or direct — goes through here; [runPrRemedy] decides
277311
/// what each one means. This widget deliberately knows nothing about composers.
278312
final void Function(PrRemedy remedy) onRun;
@@ -316,6 +350,13 @@ class PrCtaButton extends ConsumerWidget {
316350
ref,
317351
status: status,
318352
onRun: onRun,
353+
projectId: projectId,
354+
// Resolved from the snapshot rather than passed in, so the inline value is
355+
// whatever the latest broadcast says.
356+
worktree: ref
357+
.watch(reposProvider)
358+
.locateWorktree(worktreePath)
359+
?.worktree,
319360
),
320361
builder: (context, controller, _) => _SplitButton(
321362
label: cta.label,

0 commit comments

Comments
 (0)