diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db deleted file mode 100644 index 7f9770bd..00000000 Binary files a/.pnpm-store/v11/index.db and /dev/null differ diff --git a/.pnpm-store/v11/projects/739d8fce9f21779d2680f9e8d181f4ee b/.pnpm-store/v11/projects/739d8fce9f21779d2680f9e8d181f4ee deleted file mode 120000 index 1844decd..00000000 --- a/.pnpm-store/v11/projects/739d8fce9f21779d2680f9e8d181f4ee +++ /dev/null @@ -1 +0,0 @@ -../../../server \ No newline at end of file diff --git a/app/lib/desktop/chat/desktop_sidebar.dart b/app/lib/desktop/chat/desktop_sidebar.dart index 5582c50d..e28e7120 100644 --- a/app/lib/desktop/chat/desktop_sidebar.dart +++ b/app/lib/desktop/chat/desktop_sidebar.dart @@ -15,6 +15,7 @@ import '../../ui/docs/docs_popover.dart'; import '../../ui/docs/doc_preview.dart'; import '../../ui/ports/ports_popover.dart'; import '../../ui/ports/session_ports_glyph.dart'; +import '../../ui/widgets/lands_in_picker.dart'; import '../../ui/widgets/pr_state_style.dart'; import '../../ui/project/folder_browser.dart'; import '../../ui/widgets/connection_chip.dart'; @@ -647,6 +648,27 @@ class _WorktreeGroupState extends ConsumerState<_WorktreeGroup> { switch (action) { case 'rename': _renameBranch(); + case 'landsIn': + // NOT `sheet: true`: desktop wants the + // dialog form of the picker, not a bottom + // sheet. It persists the choice itself. + // Re-derive the live worktree: a snapshot + // may have retargeted or moved it while the + // menu was open, so act on today's value. + final live = + ref + .read(reposProvider) + .locateWorktree(worktree.path) + ?.worktree ?? + worktree; + unawaited( + showLandsInPicker( + context, + ref, + projectId: repo.id, + worktree: live, + ), + ); case 'delete': _deleteWorktree(); case 'ports': @@ -665,7 +687,9 @@ class _WorktreeGroupState extends ConsumerState<_WorktreeGroup> { } }, ) - else if (worktree.hasChanges) + // See worktree_row.dart: suppress rather than + // publish a partial count. + else if (worktree.showsDiff) DiffChip( insertions: worktree.insertions, deletions: worktree.deletions, @@ -836,8 +860,12 @@ class _WorktreeGroupState extends ConsumerState<_WorktreeGroup> { /// The worktree row's hover overflow menu (triple dots that replace the diff /// pill on hover). Reports the chosen action up to [_WorktreeGroupState], which /// owns the dialogs and store calls with a context/ref that outlives the menu. -/// "Rename branch" and "Delete worktree" are disabled for the primary worktree; -/// "Rename branch" is also disabled for detached worktrees and open PRs. +/// "Rename branch", "Lands in" and "Delete worktree" are disabled for the +/// primary worktree and for detached worktrees; "Rename branch" is additionally +/// disabled for an open PR, but "Lands in" is NOT (retargeting an open PR is a +/// first-class operation). Because this menu replaces the diff pill on hover, +/// "Lands in" prints the current target inline — the pill is not visible to +/// glance at while the menu is open. class _WorktreeMenuButton extends ConsumerWidget { const _WorktreeMenuButton({ required this.worktree, @@ -856,6 +884,11 @@ class _WorktreeMenuButton extends ConsumerWidget { final isPrimary = worktree.isPrimary; final isDetached = worktree.branch == null; final canRename = !_hasOpenPr && !isPrimary && !isDetached; + // Retargeting shares rename's structural guards (no primary, no detached) + // but pointedly NOT its open-PR block: renaming orphans a PR's head, while + // retargeting an open PR is a first-class operation (`gh pr edit --base`). + // So an open PR leaves this enabled while it disables Rename — deliberate. + final canRetarget = !isPrimary && !isDetached; // The count is a glance at what this branch is serving; the item routes to // the global Ports screen either way (D8), so it shows even at zero. final portCount = ref.watch(portsForWorktreeProvider(worktree.path)).length; @@ -891,6 +924,50 @@ class _WorktreeMenuButton extends ConsumerWidget { ), ), const PopupMenuDivider(), + PopupMenuItem( + value: 'landsIn', + enabled: canRetarget, + height: 36, + child: Tooltip( + message: isPrimary + ? 'This is where branches land, not one that lands' + : isDetached + ? 'This worktree has no branch to land' + : '', + // Bare Text + a trailing value, no leading icon — matching the + // other items in this menu. The `⋯` button REPLACES the diff pill + // on hover, so the user cannot glance back at the pill for the + // current target while this menu is open; the item prints its own + // current value on the right instead. + child: Row( + children: [ + Text( + 'Lands in', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: canRetarget ? null : Theme.of(context).disabledColor, + ), + ), + const Spacer(), + if (worktree.targetBranch != null) ...[ + const SizedBox(width: kSpace12), + Flexible( + child: Text( + worktree.targetBranch!, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.mono + .copyWith( + color: canRetarget + ? Theme.of(context).colorScheme.outline + : Theme.of(context).disabledColor, + ), + ), + ), + ], + ], + ), + ), + ), + const PopupMenuDivider(), PopupMenuItem( value: 'delete', enabled: !isPrimary, diff --git a/app/lib/desktop/chat/new_worktree_dialog.dart b/app/lib/desktop/chat/new_worktree_dialog.dart index 7d17918a..0ea57c3a 100644 --- a/app/lib/desktop/chat/new_worktree_dialog.dart +++ b/app/lib/desktop/chat/new_worktree_dialog.dart @@ -62,7 +62,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> { String? _projectId; _WorktreeFrom _source = _WorktreeFrom.newBranch; String? _existingWorktreePath; - String? _baseBranch; + String? _targetBranch; int? _prNumber; Future>? _prsFuture; @@ -81,7 +81,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> { widget.initialProjectId ?? _activeGroupRepo() ?? (repos.isNotEmpty ? repos.first.id : null); - _baseBranch = _defaultBranchFor(_projectId); + _targetBranch = _defaultBranchFor(_projectId); } @override @@ -115,9 +115,9 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> { return null; } - /// The base branch actually used: [_baseBranch] when it is a live option, + /// The target branch actually used: [_targetBranch] when it is a live option, /// else the first option (what [_newBranchPanel] displays). - String? _effectiveBaseBranch(String? projectId) { + String? _effectiveTargetBranch(String? projectId) { RepoInfo? repo; for (final r in ref.read(reposProvider).repos) { if (r.id == projectId) repo = r; @@ -125,8 +125,8 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> { final options = repo == null ? const [] : branchOptionsForRepo(repo); - if (options.isEmpty) return _baseBranch; - return options.contains(_baseBranch) ? _baseBranch : options.first; + if (options.isEmpty) return _targetBranch; + return options.contains(_targetBranch) ? _targetBranch : options.first; } void _close() { @@ -148,7 +148,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> { if (projectId == null || projectId == _projectId) return; setState(() { _projectId = projectId; - _baseBranch = _defaultBranchFor(projectId); + _targetBranch = _defaultBranchFor(projectId); // A different repo has a different PR list; drop the cached future so the // panel refetches when From PR is shown again. _prsFuture = null; @@ -200,7 +200,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> { final name = _branchNameCtrl.text.trim(); created = await store.createWorktree( projectId, - baseBranch: _effectiveBaseBranch(projectId), + targetBranch: _effectiveTargetBranch(projectId), branchName: name.isEmpty ? null : name, ); case _WorktreeFrom.fromPr: @@ -499,8 +499,8 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> { ) : DropdownButtonFormField( key: ValueKey('wt-branch-$_projectId'), - initialValue: options.contains(_baseBranch) - ? _baseBranch + initialValue: options.contains(_targetBranch) + ? _targetBranch : options.first, isExpanded: true, items: [ @@ -512,7 +512,7 @@ class _NewWorktreeDialogState extends ConsumerState<_NewWorktreeDialog> { ], onChanged: _creating ? null - : (v) => setState(() => _baseBranch = v), + : (v) => setState(() => _targetBranch = v), ); return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/app/lib/desktop/chat/pr_bar.dart b/app/lib/desktop/chat/pr_bar.dart index 21188123..72f5a941 100644 --- a/app/lib/desktop/chat/pr_bar.dart +++ b/app/lib/desktop/chat/pr_bar.dart @@ -4,6 +4,7 @@ import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; import '../../app/theme.dart'; import '../../store/models.dart'; +import '../../store/store.dart'; import '../../ui/widgets/icon_glyph.dart'; import '../../ui/widgets/pr_detail.dart'; import '../../ui/widgets/pr_signals.dart'; @@ -111,6 +112,10 @@ class PrComposerBar extends ConsumerWidget { const SizedBox(width: kSpace10), PrCtaButton( status: status, + // Home 1: the caret menu's "This worktree" group needs an identity to + // name; without one the group is simply absent. + projectId: projectId, + worktreePath: worktreePath, onRun: (remedy) => _run(context, ref, remedy), ), ], @@ -121,22 +126,40 @@ class PrComposerBar extends ConsumerWidget { context, status: status, pr: pr, + // Identity, so the sheet re-derives rather than freezing its facts. + projectId: projectId, + worktreePath: worktreePath, onRun: (remedy) => _run(context, ref, remedy), ); - Future _run(BuildContext context, WidgetRef ref, PrRemedy remedy) => - runPrRemedy( - context, - ref, - remedy: remedy, - status: status, - pr: pr, - projectId: projectId, - worktreePath: worktreePath, - branch: branch, - uncommittedFiles: uncommittedFiles, - onInsertPrompt: onInsertPrompt, - ); + Future _run( + BuildContext context, + WidgetRef ref, + PrRemedy remedy, + ) async { + // Re-derive from the snapshot at call time: the in-dialog "Lands in" picker + // can change the PR base (and the derived facts) while the dialog is open, so + // a remedy must act on today's target, not the build-time `status`/`pr`. + // Guard `context.mounted` first — the bar can be torn down by a snapshot and + // reading `ref` on a defunct element throws. Falls back to open-time values + // when the row is gone from the snapshot. + if (!context.mounted) return; + final at = ref.read(reposProvider).locateWorktree(worktreePath); + await runPrRemedy( + context, + ref, + remedy: remedy, + status: at == null ? status : prStatusFor(at), + pr: at == null ? pr : at.worktree.pr, + projectId: projectId, + worktreePath: worktreePath, + branch: at == null ? branch : at.worktree.branch, + uncommittedFiles: at == null + ? uncommittedFiles + : at.worktree.uncommittedFiles, + onInsertPrompt: onInsertPrompt, + ); + } } /// The sentence: a status dot, the PR number (or branch), and the loud fact. @@ -269,10 +292,21 @@ class _MoreLink extends StatelessWidget { /// * **agent prompt** — tonal fill in the fact's tone; inserts text, /// * **direct op** — solid fill; runs now (behind a confirm when destructive). class PrCtaButton extends ConsumerWidget { - const PrCtaButton({super.key, required this.status, required this.onRun}); + const PrCtaButton({ + super.key, + required this.status, + required this.onRun, + this.projectId, + this.worktreePath, + }); final PrStatus status; + /// Identity for the menu's "Lands in" entry (Home 1). Optional: a surface with + /// no resolvable worktree just does not show the group. + final String? projectId; + final String? worktreePath; + /// Every action — prompt or direct — goes through here; [runPrRemedy] decides /// what each one means. This widget deliberately knows nothing about composers. final void Function(PrRemedy remedy) onRun; @@ -316,6 +350,13 @@ class PrCtaButton extends ConsumerWidget { ref, status: status, onRun: onRun, + projectId: projectId, + // Resolved from the snapshot rather than passed in, so the inline value is + // whatever the latest broadcast says. + worktree: ref + .watch(reposProvider) + .locateWorktree(worktreePath) + ?.worktree, ), builder: (context, controller, _) => _SplitButton( label: cta.label, diff --git a/app/lib/store/models.dart b/app/lib/store/models.dart index b2eb7a9b..c8065759 100644 --- a/app/lib/store/models.dart +++ b/app/lib/store/models.dart @@ -844,6 +844,9 @@ class Worktree { required this.deletions, required this.filesChanged, required this.sessionIds, + this.targetBranch, + this.targetResolved = true, + this.retargetedFrom, this.uncommittedFiles = 0, this.aheadCount = 0, this.behindCount = 0, @@ -860,6 +863,33 @@ class Worktree { final int filesChanged; final List sessionIds; + /// The branch this worktree's work lands in: what [insertions]/[deletions] + /// measure against (`git diff target...HEAD` — what a PR into it would + /// contain), what a PR will target, and what a wrap-up fast-forwards. + /// + /// Null for the primary checkout (it *is* where branches land) and for a + /// detached worktree (no branch to land). + final String? targetBranch; + + /// False when [targetBranch] could not be resolved (deleted, never fetched): + /// the diff numbers are then working-tree-only and the committed delta is + /// unknown. Prefer [showsDiff] over reading this directly. + /// + /// Defaults to true so an older server that sends neither field keeps today's + /// rendering instead of blanking every pill. + final bool targetResolved; + + /// The target this one replaced, when makit changed it automatically: the + /// branch we were aiming at vanished without a wrap-up, so we fell back to the + /// repo default (or to wherever the chain actually landed). + /// + /// Present so the change can be **announced**. A silent repoint moves this + /// worktree's diff and its future pull request to a different destination, and + /// doing that invisibly is how someone opens a PR against the wrong branch. + /// Cleared once the user picks a target explicitly — by then they own the value + /// and there is nothing left to tell them. + final String? retargetedFrom; + /// Files with uncommitted changes (staged + unstaged + untracked). final int uncommittedFiles; @@ -875,6 +905,19 @@ class Worktree { bool get hasChanges => insertions > 0 || deletions > 0 || filesChanged > 0; + /// True when the target exists but could not be resolved — the one state that + /// needs explaining rather than rendering. + bool get targetUnresolved => targetBranch != null && !targetResolved; + + /// Whether the +/- diff may be shown. + /// + /// Not just [hasChanges]: when the target cannot be resolved the numbers are a + /// working-tree-only figure, so painting them would assert a committed delta + /// that was never measured. The failure mode is not a zero but a *plausible + /// small* count — which reads as "barely diverged" on a worktree that may be + /// far ahead — so suppression has to be explicit. + bool get showsDiff => hasChanges && !targetUnresolved; + static Worktree? fromJson(Map j) { final path = j['path']; if (path is! String) return null; @@ -887,6 +930,15 @@ class Worktree { insertions: (j['insertions'] as num?)?.toInt() ?? 0, deletions: (j['deletions'] as num?)?.toInt() ?? 0, filesChanged: (j['filesChanged'] as num?)?.toInt() ?? 0, + targetBranch: j['targetBranch'] is String + ? j['targetBranch'] as String + : null, + targetResolved: j['targetResolved'] is bool + ? j['targetResolved'] as bool + : true, + retargetedFrom: j['retargetedFrom'] is String + ? j['retargetedFrom'] as String + : null, uncommittedFiles: (j['uncommittedFiles'] as num?)?.toInt() ?? 0, aheadCount: (j['aheadCount'] as num?)?.toInt() ?? 0, behindCount: (j['behindCount'] as num?)?.toInt() ?? 0, @@ -905,6 +957,95 @@ class Worktree { } } +/// Why a branch is offered as a target — the picker's section headers. +/// +/// `defaultBranch` rather than `default`, which is a Dart keyword. +enum TargetCandidateGroup { + /// The closest ancestor branch: the honest suggestion, and the one today's + /// pill gets wrong for a stacked worktree. + forkedFrom('Forked from'), + + /// The repo's default branch — what you want once a stack lands. + defaultBranch('Repo default'), + + /// Checked out in another worktree: the stacked case. + worktree('Other worktrees'), + + /// Everything else, behind a filter. + other('All branches'); + + const TargetCandidateGroup(this.label); + + /// Section header text. + final String label; + + /// Wire value -> enum, defaulting to [other] so a newer server's group name + /// degrades to "listed under All branches" instead of throwing. + static TargetCandidateGroup fromWire(String? raw) => switch (raw) { + 'forkedFrom' => TargetCandidateGroup.forkedFrom, + 'default' => TargetCandidateGroup.defaultBranch, + 'worktree' => TargetCandidateGroup.worktree, + _ => TargetCandidateGroup.other, + }; +} + +/// One row in the "Lands in" picker. +class TargetCandidate { + const TargetCandidate({ + required this.branch, + required this.group, + required this.onRemote, + required this.isSelf, + this.insertions, + this.deletions, + }); + + final String branch; + final TargetCandidateGroup group; + + /// Whether the branch exists on a remote. A pull-request base must, so a + /// local-only branch is shown disabled with a reason rather than accepted and + /// then refused by `gh`. + final bool onRemote; + + /// True for the worktree's own branch: listed so the picker can explain why it + /// is not selectable, rather than leaving an unexplained gap. + final bool isSelf; + + /// What the diff would become. Null when the server did not preview this + /// candidate (only the ranked few are previewed) or could not measure it. + final int? insertions; + final int? deletions; + + bool get hasPreview => insertions != null && deletions != null; + + /// Whether picking this row is allowed. + bool get selectable => !isSelf && onRemote; + + /// Why it is not selectable, in the user's terms — null when it is. + /// + /// Follows the "explain the block, don't hide it" convention the worktree and + /// PR action menus already use. + String? get blockedReason { + if (isSelf) return 'this worktree'; + if (!onRemote) return 'not pushed yet'; + return null; + } + + static TargetCandidate? fromJson(Map j) { + final branch = j['branch']; + if (branch is! String || branch.isEmpty) return null; + return TargetCandidate( + branch: branch, + group: TargetCandidateGroup.fromWire(j['group'] as String?), + onRemote: j['onRemote'] != false, + isSelf: j['isSelf'] == true, + insertions: (j['insertions'] as num?)?.toInt(), + deletions: (j['deletions'] as num?)?.toInt(), + ); + } +} + /// A repo on the home screen: a [Project] enriched with git intelligence — /// its default/current branch and live worktrees. /// Where an effective per-repo value came from. Drives the badge; the app is told @@ -1356,29 +1497,29 @@ class WrapUpReport { const WrapUpReport({ this.branchDeleted, this.branchReason, - this.baseBranch, - this.baseUpdated = false, - this.baseReason, + this.targetBranch, + this.targetUpdated = false, + this.targetReason, }); /// The local branch that was deleted, or null for a detached worktree — or for /// one whose deletion failed, in which case [branchReason] says why. final String? branchDeleted; - /// Why the branch survived when it should have gone. Like [baseReason] this is + /// Why the branch survived when it should have gone. Like [targetReason] this is /// reported rather than thrown: the worktree is already removed by then, so the /// job partly succeeded and the client cannot retry it. final String? branchReason; /// The branch that was caught up, or null when none could be resolved. - final String? baseBranch; + final String? targetBranch; - /// True when [baseBranch] actually moved. - final bool baseUpdated; + /// True when [targetBranch] actually moved. + final bool targetUpdated; - /// Why [baseBranch] was not updated, when that is worth telling the user. + /// Why [targetBranch] was not updated, when that is worth telling the user. /// Null for the benign "already up to date" case — that is not a problem. - final String? baseReason; + final String? targetReason; /// Tolerant decode: an empty/garbage ack degrades to "nothing reported" /// rather than throwing, because by the time this arrives the worktree has @@ -1390,9 +1531,16 @@ class WrapUpReport { branchReason: j['branchReason'] is String ? j['branchReason'] as String : null, - baseBranch: j['baseBranch'] is String ? j['baseBranch'] as String : null, - baseUpdated: j['baseUpdated'] == true, - baseReason: j['baseReason'] is String ? j['baseReason'] as String : null, + // `targetBranch` is the name; `baseBranch` is read for one release so a + // server that predates the rename still produces a complete report. + targetBranch: j['targetBranch'] is String + ? j['targetBranch'] as String + : (j['baseBranch'] is String ? j['baseBranch'] as String : null), + // Same one-release aliases as `targetBranch` above. + targetUpdated: j['targetUpdated'] == true || j['baseUpdated'] == true, + targetReason: j['targetReason'] is String + ? j['targetReason'] as String + : (j['baseReason'] is String ? j['baseReason'] as String : null), ); /// One line for a snackbar, e.g. `Removed feat/x · main updated`, or @@ -1405,8 +1553,8 @@ class WrapUpReport { 'Worktree removed', // Never silently imply the branch went when it did not. if (branchDeleted == null && branchReason != null) 'branch kept', - if (baseBranch != null) - baseUpdated ? '$baseBranch updated' : '$baseBranch unchanged', + if (targetBranch != null) + targetUpdated ? '$targetBranch updated' : '$targetBranch unchanged', ]; return parts.join(' · '); } @@ -1415,7 +1563,7 @@ class WrapUpReport { /// Null when everything went as advertised — both legs are best-effort, and /// either can have something to say. String? get detail { - final reasons = [?branchReason, ?baseReason]; + final reasons = [?branchReason, ?targetReason]; return reasons.isEmpty ? null : reasons.join('\n'); } } diff --git a/app/lib/store/store.dart b/app/lib/store/store.dart index 6941e8d1..ba420e6d 100644 --- a/app/lib/store/store.dart +++ b/app/lib/store/store.dart @@ -945,23 +945,30 @@ class StoreController extends StateNotifier { } /// Create a new worktree up front (the + New worktree flow) with an - /// auto-generated branch off [baseBranch], or a slugified [branchName] when + /// auto-generated branch off [targetBranch], or a slugified [branchName] when /// supplied. Returns the new worktree's path + branch; the caller then lands /// on it to pick a harness. The server broadcasts a repos.snapshot so the /// sidebar shows the new worktree. Future<({String path, String? branch})> createWorktree( String projectId, { - String? baseBranch, + String? targetBranch, String? branchName, }) async { - final ack = await _ref - .read(connectionControllerProvider.notifier) - .request(MsgType.cmd, { - 'kind': 'worktree.create', - 'projectId': projectId, - 'baseBranch': ?baseBranch, - 'branchName': ?branchName, - }); + final ack = await _ref.read(connectionControllerProvider.notifier).request( + MsgType.cmd, + { + 'kind': 'worktree.create', + 'projectId': projectId, + 'targetBranch': ?targetBranch, + // Sent for one release as well, so a server that predates the + // base->target rename still receives the value instead of silently + // falling back to the repo default. + // TODO(SPEC-51): drop the `baseBranch` alias one release after the + // server ships with `targetBranch`. + 'baseBranch': ?targetBranch, + 'branchName': ?branchName, + }, + ); final path = ack['path'] as String?; if (path == null) throw StateError('server did not return a worktree path'); return (path: path, branch: ack['branch'] as String?); @@ -983,6 +990,53 @@ class StoreController extends StateNotifier { .toList(); } + /// Ranked candidates for the "Lands in" picker, grouped by why each is a + /// candidate, with a diff preview on the leading few. + /// + /// A read: the server does not broadcast for this, so opening a picker costs + /// other clients nothing. + Future> targetCandidates( + String projectId, + String worktreePath, + ) async { + final ack = await _ref + .read(connectionControllerProvider.notifier) + .request(MsgType.cmd, { + 'kind': 'worktree.targetCandidates', + 'projectId': projectId, + 'worktreePath': worktreePath, + }); + final raw = (ack['candidates'] as List?) ?? const []; + return raw + .whereType>() + .map((m) => TargetCandidate.fromJson(Map.from(m))) + .whereType() + .toList(); + } + + /// Set the branch a worktree's work lands in: what the +/- diff measures + /// against, what a PR will target, and what a wrap-up fast-forwards. + /// + /// The server validates the ref, persists it, and only then broadcasts a + /// repos.snapshot — so every consumer of the diff numbers corrects itself + /// without the caller doing anything. Deliberately returns nothing to apply + /// locally: the UI must render from the snapshot, never from an optimistic + /// local guess, or a rejected change would leave the picker lying. + Future setWorktreeTarget( + String projectId, + String worktreePath, + String targetBranch, + ) async { + await _ref + .read(connectionControllerProvider.notifier) + .request(MsgType.cmd, { + 'kind': 'worktree.setTarget', + 'projectId': projectId, + 'worktreePath': worktreePath, + 'targetBranch': targetBranch, + }); + } + /// Create a worktree that checks out an existing PR's head branch. Returns /// the new worktree's path + branch. Future<({String path, String? branch})> createWorktreeFromPr( @@ -1059,7 +1113,7 @@ class StoreController extends StateNotifier { /// Tidy up after a pull request ended: remove the worktree, delete its /// branch, and fast-forward the branch the PR merged into. /// - /// [baseBranch] is the PR's own `baseRefName`; pass null and the server falls + /// [targetBranch] is the PR's own `baseRefName`; pass null and the server falls /// back to the repo's default branch. Returns the server's report, because the /// base-branch leg is best-effort — the caller has to be able to tell "tidied /// and caught main up" from "tidied, main left alone because it had local @@ -1067,18 +1121,25 @@ class StoreController extends StateNotifier { Future wrapUpWorktree( String projectId, String worktreePath, { - String? baseBranch, + String? targetBranch, String? expectBranch, }) async { - final ack = await _ref - .read(connectionControllerProvider.notifier) - .request(MsgType.cmd, { - 'kind': 'worktree.wrapUp', - 'projectId': projectId, - 'worktreePath': worktreePath, - 'baseBranch': ?baseBranch, - 'expectBranch': ?expectBranch, - }); + final ack = await _ref.read(connectionControllerProvider.notifier).request( + MsgType.cmd, + { + 'kind': 'worktree.wrapUp', + 'projectId': projectId, + 'worktreePath': worktreePath, + 'targetBranch': ?targetBranch, + // See `worktree.create` above: one-release compatibility. Here it also + // guards the only irreversible case — a server reading the old key would + // otherwise fast-forward the WRONG branch and report success. + // TODO(SPEC-51): drop the `baseBranch` alias one release after the + // server ships with `targetBranch`. + 'baseBranch': ?targetBranch, + 'expectBranch': ?expectBranch, + }, + ); return WrapUpReport.fromJson(ack); } diff --git a/app/lib/ui/home/new_session_sheet.dart b/app/lib/ui/home/new_session_sheet.dart index da26c165..9d7819b8 100644 --- a/app/lib/ui/home/new_session_sheet.dart +++ b/app/lib/ui/home/new_session_sheet.dart @@ -19,7 +19,7 @@ class NewSessionChoice { this.agent, this.source = WorktreeSource.newBranch, this.worktreePath, - this.baseBranch, + this.targetBranch, this.prNumber, this.configOptions = const [], }); @@ -33,8 +33,9 @@ class NewSessionChoice { /// The existing worktree path when [source] is [WorktreeSource.existing]. final String? worktreePath; - /// The base branch to fork from when [source] is [WorktreeSource.newBranch]. - final String? baseBranch; + /// The target branch the new worktree's work will land in (and which it forks + /// from) when [source] is [WorktreeSource.newBranch]. + final String? targetBranch; /// The PR number to fork from when [source] is [WorktreeSource.fromPr]. final int? prNumber; @@ -205,7 +206,7 @@ class _NewSessionSheetState extends State { agent: _agent, source: _source, worktreePath: _source == WorktreeSource.existing ? _worktreePath : null, - baseBranch: _source == WorktreeSource.newBranch ? _branch : null, + targetBranch: _source == WorktreeSource.newBranch ? _branch : null, prNumber: _source == WorktreeSource.fromPr ? _prNumber : null, configOptions: _picksList, ), diff --git a/app/lib/ui/home/repo_card.dart b/app/lib/ui/home/repo_card.dart index 3e6c9ae3..514006a5 100644 --- a/app/lib/ui/home/repo_card.dart +++ b/app/lib/ui/home/repo_card.dart @@ -374,7 +374,7 @@ class _RepoCardState extends ConsumerState { ); if (base == null) return; try { - final wt = await store.createWorktree(repo.id, baseBranch: base); + final wt = await store.createWorktree(repo.id, targetBranch: base); status.success( 'Created ${wt.branch ?? wt.path}', source: StatusSources.worktree, diff --git a/app/lib/ui/home/repo_chips.dart b/app/lib/ui/home/repo_chips.dart index 008d18f9..25a2761b 100644 --- a/app/lib/ui/home/repo_chips.dart +++ b/app/lib/ui/home/repo_chips.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -5,6 +7,7 @@ import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; import '../../app/theme.dart'; import '../../store/models.dart'; +import '../../store/store.dart'; import '../widgets/pr_detail.dart'; import '../widgets/pr_signals.dart'; import '../widgets/pr_tone.dart'; @@ -147,20 +150,41 @@ class PrStatusChip extends ConsumerWidget { context, status: status, pr: worktree.pr, + // Identity, so the sheet re-derives instead of freezing its facts — + // it now hosts the "Lands in" picker, which changes those facts. + projectId: repo.id, + worktreePath: worktree.path, sheet: true, canInsertPrompt: onInsertPrompt != null, - onRun: (remedy) => runPrRemedy( - context, - ref, - remedy: remedy, - status: status, - pr: worktree.pr, - projectId: repo.id, - worktreePath: worktree.path, - branch: worktree.branch, - uncommittedFiles: worktree.uncommittedFiles, - onInsertPrompt: onInsertPrompt ?? (_) {}, - ), + onRun: (remedy) { + // Guard `context.mounted` before touching `ref`: the chip can be + // removed from the tree by a repos snapshot while the sheet is still + // open, and reading `ref` (here and inside runPrRemedy's `ref.status`) + // on a defunct element throws. + if (!context.mounted) return; + // Re-derive from the snapshot at invocation time. This sheet hosts + // the "Lands in" picker, so the PR's base — and every fact derived + // from it — can change while it is open; a remedy run against the + // build-time `pr`/`status` would target the stale base (deriving + // `PrOpTarget.targetBranch` from an old `pr.baseRefName`). Falls back + // to the open-time values when the snapshot no longer carries the row. + final at = ref.read(reposProvider).locateWorktree(worktree.path); + final live = at?.worktree ?? worktree; + unawaited( + runPrRemedy( + context, + ref, + remedy: remedy, + status: at == null ? status : prStatusFor(at), + pr: live.pr, + projectId: repo.id, + worktreePath: live.path, + branch: live.branch, + uncommittedFiles: live.uncommittedFiles, + onInsertPrompt: onInsertPrompt ?? (_) {}, + ), + ); + }, ), child: ConstrainedBox( constraints: const BoxConstraints(minHeight: kTouchRow), diff --git a/app/lib/ui/home/start_session.dart b/app/lib/ui/home/start_session.dart index 628961ca..4a512f38 100644 --- a/app/lib/ui/home/start_session.dart +++ b/app/lib/ui/home/start_session.dart @@ -100,8 +100,9 @@ Future startSessionFlow( case WorktreeSource.newBranch: final wt = await store.createWorktree( repo.id, - baseBranch: - choice.baseBranch ?? (branches.isEmpty ? null : branches.first), + targetBranch: + choice.targetBranch ?? + (branches.isEmpty ? null : branches.first), ); worktreePath = wt.path; branch = wt.branch; diff --git a/app/lib/ui/home/worktree_actions.dart b/app/lib/ui/home/worktree_actions.dart index 8e356885..310bdac3 100644 --- a/app/lib/ui/home/worktree_actions.dart +++ b/app/lib/ui/home/worktree_actions.dart @@ -2,10 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; -import '../../store/models.dart'; -import '../../store/store.dart'; import '../../status/status_event.dart'; import '../../status/status_providers.dart'; +import '../../store/models.dart'; +import '../../store/store.dart'; +import '../widgets/lands_in_picker.dart'; import '../widgets/sheet_header.dart'; /// Whether [w]'s branch can be renamed. Mirrors the desktop sidebar's guards: @@ -14,6 +15,16 @@ import '../widgets/sheet_header.dart'; bool canRenameWorktree(Worktree w) => !w.isPrimary && w.branch != null && w.pr?.state.toUpperCase() != 'OPEN'; +/// Whether [w]'s target — the branch its work lands in — can be changed. +/// +/// Same two exclusions as rename (the primary checkout *is* where branches +/// land; a detached worktree has no branch to land), but pointedly NOT gated on +/// an open PR the way [canRenameWorktree] is. Renaming out from under a PR +/// orphans its head, so that stays blocked — but retargeting an open PR is a +/// first-class operation (`gh pr edit --base`), so an open PR must leave this +/// enabled. The asymmetry is deliberate. +bool canRetargetWorktree(Worktree w) => !w.isPrimary && w.branch != null; + /// Whether [w] can be deleted. Everything but the primary checkout — deleting /// that would take the repo with it. bool canDeleteWorktree(Worktree w) => !w.isPrimary; @@ -31,49 +42,129 @@ Future showWorktreeActions( required RepoInfo repo, required Worktree worktree, }) async { + // Renamed locally: the builder below shadows this with the LIVE value, and two + // identifiers one letter apart would be an easy way to reintroduce the bug. + final w = worktree; final action = await showModalBottomSheet( context: context, showDragHandle: true, - builder: (sheetContext) { - final cs = Theme.of(sheetContext).colorScheme; - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SheetHeader(title: worktree.branch ?? 'detached'), - ListTile( - enabled: canRenameWorktree(worktree), - leading: const Icon(PhosphorIconsLight.textAa), - title: const Text('Rename branch'), - subtitle: canRenameWorktree(worktree) - ? null - : Text(_renameBlockedReason(worktree)), - onTap: () => Navigator.pop(sheetContext, 'rename'), + builder: (_) { + // Scope the watch to the sheet's OWN element via a Consumer. `ref` here is + // the calling widget's, so `ref.watch` rebuilds the caller, not this modal + // route — the target and guards would otherwise freeze at open-time. The + // Consumer's local ref subscribes inside the sheet's own tree, so a snapshot + // arriving while it sits open actually repaints it. + return Consumer( + builder: (sheetContext, ref, _) { + final cs = Theme.of(sheetContext).colorScheme; + // Re-derive from the snapshot instead of closing over the `worktree` we were + // handed. Tapping "Lands in" pops this sheet before the picker opens, so the + // obvious staleness path is already closed — but a snapshot can also arrive + // while the sheet sits open (the agent commits, another client retargets), and + // then the target and guards printed here would describe a state that no + // longer exists. Falls back to the passed-in value for a worktree the snapshot + // no longer carries, so a removal cannot blank the sheet mid-tap. + final worktree = + ref.watch(reposProvider).locateWorktree(w.path)?.worktree ?? w; + return SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SheetHeader(title: worktree.branch ?? 'detached'), + // Under the header so the sheet says, at a glance, where this branch + // lands. The header already IS the branch, so the source half is + // omitted — printing it twice would just waste the line. Only shown + // when there is a branch that lands (i.e. the retarget guard holds). + if (canRetargetWorktree(worktree)) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: LandsInLine( + targetBranch: worktree.targetBranch, + targetResolved: worktree.targetResolved, + ), + ), + ListTile( + enabled: canRenameWorktree(worktree), + leading: const Icon(PhosphorIconsLight.textAa), + title: const Text('Rename branch'), + subtitle: canRenameWorktree(worktree) + ? null + : Text(_renameBlockedReason(worktree)), + onTap: () => Navigator.pop(sheetContext, 'rename'), + ), + // Between Rename and Delete: like Rename it is a non-destructive + // branch property, so the two are siblings and the destructive + // Delete stays last. + ListTile( + enabled: canRetargetWorktree(worktree), + leading: const Icon(kLandsInIcon), + title: const Text('Lands in'), + // Enabled: the current target (so the row states today's value). + // Disabled: why, following the same visible-but-disabled + // convention as Rename. + subtitle: canRetargetWorktree(worktree) + ? (worktree.targetBranch == null + ? null + : Text(worktree.targetBranch!)) + : Text(_landsInBlockedReason(worktree)), + onTap: () => Navigator.pop(sheetContext, 'landsIn'), + ), + ListTile( + enabled: canDeleteWorktree(worktree), + leading: Icon(PhosphorIconsLight.trash, color: cs.error), + title: Text( + 'Delete worktree', + style: TextStyle(color: cs.error), + ), + subtitle: canDeleteWorktree(worktree) + ? null + : const Text('The primary checkout cannot be removed'), + onTap: () => Navigator.pop(sheetContext, 'delete'), + ), + ], + ), ), - ListTile( - enabled: canDeleteWorktree(worktree), - leading: Icon(PhosphorIconsLight.trash, color: cs.error), - title: Text('Delete worktree', style: TextStyle(color: cs.error)), - subtitle: canDeleteWorktree(worktree) - ? null - : const Text('The primary checkout cannot be removed'), - onTap: () => Navigator.pop(sheetContext, 'delete'), - ), - ], - ), + ); + }, ); }, ); if (action == null || !context.mounted) return; + // Re-derive the live worktree before acting: a snapshot may have retargeted or + // moved it while the sheet sat open, and every action below must operate on + // today's value, not the one captured when the menu opened (the builder above + // is careful to do this for display, but that value dies with the sheet). Falls + // back to the passed-in value for a worktree the snapshot no longer carries. + final live = + ref.read(reposProvider).locateWorktree(w.path)?.worktree ?? worktree; switch (action) { case 'rename': - await _renameBranch(context, ref, repo: repo, worktree: worktree); + await _renameBranch(context, ref, repo: repo, worktree: live); + case 'landsIn': + // `sheet: true`: on touch the picker is a bottom sheet, matching the + // surface it was launched from. It persists the choice itself. + await showLandsInPicker( + context, + ref, + projectId: repo.id, + worktree: live, + sheet: true, + ); case 'delete': - await _deleteWorktree(context, ref, repo: repo, worktree: worktree); + await _deleteWorktree(context, ref, repo: repo, worktree: live); } } +/// Why "Lands in" is disabled — shown under the greyed row like the rename +/// reason. No open-PR case here on purpose: an open PR does not block +/// retargeting (see [canRetargetWorktree]). +String _landsInBlockedReason(Worktree w) { + if (w.isPrimary) return 'This is where branches land, not one that lands'; + return 'This worktree has no branch to land'; +} + /// Why "Rename branch" is disabled — shown under the greyed row so the block is /// explained rather than just enforced. String _renameBlockedReason(Worktree w) { diff --git a/app/lib/ui/home/worktree_row.dart b/app/lib/ui/home/worktree_row.dart index 88ed1696..d11b71c1 100644 --- a/app/lib/ui/home/worktree_row.dart +++ b/app/lib/ui/home/worktree_row.dart @@ -240,7 +240,10 @@ class _WorktreeRowState extends ConsumerState { crossAxisAlignment: WrapCrossAlignment.center, children: [ if (isDefault) TagChip(label: 'default', color: cs.outline), - if (worktree.hasChanges) + // `showsDiff`, not `hasChanges`: an unresolvable target + // yields a working-tree-only count, and painting it would + // assert a committed delta we never measured. + if (worktree.showsDiff) DiffChip( insertions: worktree.insertions, deletions: worktree.deletions, diff --git a/app/lib/ui/session/session_pr_chip.dart b/app/lib/ui/session/session_pr_chip.dart index 163224b6..40f2da48 100644 --- a/app/lib/ui/session/session_pr_chip.dart +++ b/app/lib/ui/session/session_pr_chip.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/theme.dart'; import '../../store/models.dart'; +import '../../store/store.dart'; import '../widgets/pr_detail.dart'; import '../widgets/pr_signals.dart'; import '../widgets/pr_tone.dart'; @@ -61,20 +64,39 @@ class SessionPrChip extends ConsumerWidget { context, status: status, pr: pr, + // Identity, so the sheet re-derives its facts (it hosts the "Lands in" + // picker, which changes them) rather than painting open-time values. + projectId: projectId, + worktreePath: worktreePath, sheet: true, canInsertPrompt: onInsertPrompt != null, - onRun: (remedy) => runPrRemedy( - context, - ref, - remedy: remedy, - status: status, - pr: pr, - projectId: projectId, - worktreePath: worktreePath, - branch: branch, - uncommittedFiles: uncommittedFiles, - onInsertPrompt: onInsertPrompt ?? (_) {}, - ), + onRun: (remedy) { + // Re-derive from the snapshot at invocation time: this sheet hosts the + // "Lands in" picker, so the PR base and the facts derived from it can + // change while it is open; a remedy run against the build-time values + // would target the stale base. Guard `context.mounted` first — the chip + // can be removed by a snapshot while the sheet is up, and reading `ref` + // (here and inside runPrRemedy's `ref.status`) on a defunct element + // throws. Falls back to open-time values when the row is gone. + if (!context.mounted) return; + final at = ref.read(reposProvider).locateWorktree(worktreePath); + unawaited( + runPrRemedy( + context, + ref, + remedy: remedy, + status: at == null ? status : prStatusFor(at), + pr: at == null ? pr : at.worktree.pr, + projectId: projectId, + worktreePath: worktreePath, + branch: at == null ? branch : at.worktree.branch, + uncommittedFiles: at == null + ? uncommittedFiles + : at.worktree.uncommittedFiles, + onInsertPrompt: onInsertPrompt ?? (_) {}, + ), + ); + }, ), // The chip is the only way into the PR sheet from a session, so it is a // control and gets a control's target (kTouchRow). The tint stays painted diff --git a/app/lib/ui/widgets/lands_in_picker.dart b/app/lib/ui/widgets/lands_in_picker.dart new file mode 100644 index 00000000..9457ae91 --- /dev/null +++ b/app/lib/ui/widgets/lands_in_picker.dart @@ -0,0 +1,374 @@ +/// The "Lands in" picker: choose the branch a worktree's work lands in. +/// +/// One list, two shells — a bottom sheet on touch, a `MenuAnchor` on desktop — +/// mirroring how the model picker is already built, so this introduces no new +/// pattern. The ranking and the diff previews come from the server +/// (`worktree.targetCandidates`); this file only renders them. +/// +/// Reached from three places, all of which are *disclosures* rather than +/// first-glance UI: the worktree-actions menu (the canonical home, since the +/// target is a property of a worktree and that is the only per-worktree menu), +/// the composer's `Ship it` caret menu, and the PR detail sheet's header. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; + +import '../../app/theme.dart'; +import '../../status/status_providers.dart'; +import '../../store/models.dart'; +import '../../store/store.dart'; +import 'sheet_header.dart'; + +/// The glyph that reads "lands in": a doubled caret, i.e. *append into*. +/// +/// A single arrow is the app's generic "goes to"; the doubled form distinguishes +/// a merge destination from mere navigation. +const IconData kLandsInIcon = PhosphorIconsLight.caretDoubleRight; + +/// `branch ≫ target` — the one line that says where work lands. +/// +/// Asymmetric on purpose: the source is muted, the target carries the emphasis +/// and (when [onTap] is given) the affordance. Rendered at equal weight the pair +/// reads as two unrelated facts, when the whole point is that the right half is a +/// control. +class LandsInLine extends StatelessWidget { + const LandsInLine({ + super.key, + this.sourceBranch, + required this.targetBranch, + this.targetResolved = true, + this.onTap, + this.trailing, + }); + + /// The head branch. Omitted where the surface's title already is the branch — + /// printing it twice wastes the line. + final String? sourceBranch; + + /// Where it lands. Null renders a placeholder rather than an empty gap. + final String? targetBranch; + + /// False when the target could not be resolved (deleted, unfetched): the line + /// switches to a warning tone, because a target that cannot be resolved is the + /// reason the diff numbers went missing. + final bool targetResolved; + + final VoidCallback? onTap; + + /// Optional tail, e.g. the diff numbers. + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final theme = Theme.of(context); + final unresolved = targetBranch != null && !targetResolved; + final targetColor = unresolved ? cs.statusWarningText : cs.onSurface; + final mono = theme.textTheme.labelSmall?.mono; + + final target = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (unresolved) ...[ + Icon( + PhosphorIconsLight.warning, + size: 12, + color: cs.statusWarningText, + ), + const SizedBox(width: kSpace4), + ], + Flexible( + child: Text( + targetBranch ?? 'not set', + style: mono?.copyWith( + color: targetColor, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (onTap != null) ...[ + const SizedBox(width: kSpace2), + Icon(PhosphorIconsLight.caretDown, size: 11, color: cs.outline), + ], + ], + ); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (sourceBranch != null) ...[ + Flexible( + child: Text( + sourceBranch!, + style: mono?.copyWith(color: cs.outline), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: kSpace6), + ], + Icon(kLandsInIcon, size: 14, color: cs.outline), + const SizedBox(width: kSpace6), + if (onTap == null) + target + else + InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(kRadius6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: kSpace4, + vertical: kSpace2, + ), + child: target, + ), + ), + if (trailing != null) ...[ + const SizedBox(width: kSpace6), + Text('·', style: TextStyle(color: cs.outline)), + const SizedBox(width: kSpace6), + trailing!, + ], + ], + ); + } +} + +/// `+N −M` for a candidate preview, in the diff hues. +class _Preview extends StatelessWidget { + const _Preview({required this.insertions, required this.deletions}); + final int insertions; + final int deletions; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final style = Theme.of(context).textTheme.labelXs?.mono; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('+$insertions', style: style?.copyWith(color: cs.diffAddText)), + const SizedBox(width: kSpace4), + Text('\u2212$deletions', style: style?.copyWith(color: cs.diffDelText)), + ], + ); + } +} + +/// Open the picker and apply the choice. Returns the chosen branch, or null when +/// dismissed or when the change failed. +/// +/// `sheet: true` uses a bottom sheet (touch); false uses a dialog, which is what +/// desktop surfaces that are not menus (the detail sheet header) want. +Future showLandsInPicker( + BuildContext context, + WidgetRef ref, { + required String projectId, + required Worktree worktree, + bool sheet = false, +}) async { + final store = ref.read(storeControllerProvider.notifier); + // Capture the StatusCenter BEFORE the picker opens: reading `ref.status` once + // the modal returns would hit a disposed ref if the owning widget unmounted + // while it was open, throwing and silently losing the target change (SPEC-48 + // lifetime rule — enforced by status_lifetime_test). + final status = ref.status; + // The request is in flight before the route's first build, and the user can + // dismiss the picker before it completes — in both cases a rejection would reach + // no listener and escape to the zone handler. `ignore()` marks it handled while + // still delivering to the `FutureBuilder` inside, which renders the error state. + // Same pattern as `_NewWorktreeDialogState._loadPrs`. + final candidatesFuture = store.targetCandidates(projectId, worktree.path); + candidatesFuture.ignore(); + final body = _LandsInPickerBody( + projectId: projectId, + worktree: worktree, + candidatesFuture: candidatesFuture, + ); + final chosen = sheet + ? await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (_) => SafeArea(child: body), + ) + : await showDialog( + context: context, + builder: (_) => Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420, maxHeight: 520), + child: body, + ), + ), + ); + if (chosen == null || chosen == worktree.targetBranch) return null; + try { + await store.setWorktreeTarget(projectId, worktree.path, chosen); + return chosen; + } catch (e) { + // Never leave the UI showing a value the server refused: the snapshot is the + // only source of truth, so there is nothing to roll back — just say why, on + // the Activity record where it can be copied. + status.failure( + 'Could not change where this lands', + error: e, + source: 'worktree', + ); + return null; + } +} + +class _LandsInPickerBody extends StatelessWidget { + const _LandsInPickerBody({ + required this.projectId, + required this.worktree, + required this.candidatesFuture, + }); + + final String projectId; + final Worktree worktree; + final Future> candidatesFuture; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SheetHeader(title: 'Lands in'), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, kSpace8), + child: LandsInLine( + sourceBranch: worktree.branch, + targetBranch: worktree.targetBranch, + targetResolved: worktree.targetResolved, + ), + ), + Flexible( + child: FutureBuilder>( + future: candidatesFuture, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ); + } + if (snap.hasError) { + return Padding( + padding: const EdgeInsets.all(16), + child: Text('Could not list branches: ${snap.error}'), + ); + } + final all = snap.data ?? const []; + if (all.isEmpty) { + return const Padding( + padding: EdgeInsets.all(16), + child: Text('No branches to land in.'), + ); + } + return _CandidateList(all: all, current: worktree.targetBranch); + }, + ), + ), + ], + ); + } +} + +/// The grouped list. Section headers appear as the group changes, so the server's +/// ranking drives the layout and the client never re-sorts. +class _CandidateList extends StatelessWidget { + const _CandidateList({required this.all, required this.current}); + + final List all; + final String? current; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + // A flat list of row BUILDERS (headers + candidates), so `ListView.builder` + // can lazily construct only what is on screen. The `other` group is "all + // branches", so a branch-heavy repo would otherwise materialise every + // `ListTile` on each rebuild. + final rows = []; + TargetCandidateGroup? seen; + for (final c in all) { + if (c.group != seen) { + seen = c.group; + final label = c.group.label.toUpperCase(); + rows.add( + (context) => Padding( + padding: const EdgeInsets.fromLTRB(16, kSpace12, 16, kSpace4), + child: Text( + label, + style: Theme.of(context).textTheme.labelXs?.copyWith( + color: cs.outline, + fontWeight: FontWeight.w700, + letterSpacing: 1.1, + ), + ), + ), + ); + } + final isCurrent = c.branch == current; + rows.add((context) => _CandidateRow(candidate: c, isCurrent: isCurrent)); + } + return ListView.builder( + shrinkWrap: true, + itemCount: rows.length, + itemBuilder: (context, i) => rows[i](context), + ); + } +} + +class _CandidateRow extends StatelessWidget { + const _CandidateRow({required this.candidate, required this.isCurrent}); + + final TargetCandidate candidate; + final bool isCurrent; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final c = candidate; + final reason = c.blockedReason; + return ListTile( + key: Key('landsInCandidate-${c.branch}'), + enabled: c.selectable, + // 44pt floor: this is a thumb target on touch. + minVerticalPadding: kSpace8, + leading: Icon( + c.group == TargetCandidateGroup.forkedFrom + ? PhosphorIconsLight.gitFork + : PhosphorIconsLight.gitBranch, + size: 17, + color: c.group == TargetCandidateGroup.forkedFrom + ? cs.primary + : cs.outline, + ), + title: Text( + c.branch, + style: Theme.of(context).textTheme.bodyMedium?.mono, + overflow: TextOverflow.ellipsis, + ), + // Explain the block rather than hiding the row — the same convention the + // worktree and PR action menus use for a disabled entry. + subtitle: reason == null ? null : Text(reason), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (c.hasPreview) + _Preview(insertions: c.insertions!, deletions: c.deletions!), + if (isCurrent) ...[ + const SizedBox(width: kSpace8), + Icon(PhosphorIconsLight.check, size: 16, color: cs.primary), + ], + ], + ), + onTap: c.selectable ? () => Navigator.pop(context, c.branch) : null, + ); + } +} diff --git a/app/lib/ui/widgets/pr_detail.dart b/app/lib/ui/widgets/pr_detail.dart index 0ce7645f..bd0a63b3 100644 --- a/app/lib/ui/widgets/pr_detail.dart +++ b/app/lib/ui/widgets/pr_detail.dart @@ -18,11 +18,14 @@ import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../app/theme.dart'; +import '../../status/status_event.dart'; +import '../../status/status_providers.dart'; import '../../store/models.dart'; import '../../store/prefs/preference_entries.dart'; import '../../store/prefs/preferences_providers.dart'; -import '../../status/status_event.dart'; -import '../../status/status_providers.dart'; +import '../../store/store.dart'; +import '../home/repo_chips.dart' show DiffChip; +import 'lands_in_picker.dart'; import 'pr_actions.dart'; import 'pr_signals.dart'; import 'pr_state_style.dart'; @@ -45,6 +48,11 @@ Future showPrDetail( required void Function(PrRemedy remedy) onRun, bool sheet = false, bool canInsertPrompt = true, + + /// Identity so the sheet can re-derive its facts live rather than freeze them + /// at open time (see [PrDetailBody.status]). + String? projectId, + String? worktreePath, }) { // On mobile the sheet *is* the PR surface — there is no persistent bar // carrying the call to action — so it pins one, and opens on the decision @@ -55,6 +63,8 @@ Future showPrDetail( onRun: onRun, showCta: sheet, canInsertPrompt: canInsertPrompt, + projectId: projectId, + worktreePath: worktreePath, ); if (sheet) { return showModalBottomSheet( @@ -76,7 +86,7 @@ Future showPrDetail( } /// The shared body: header, the facts (with remedies), then the CI checks. -class PrDetailBody extends StatelessWidget { +class PrDetailBody extends ConsumerWidget { const PrDetailBody({ super.key, required this.status, @@ -84,12 +94,29 @@ class PrDetailBody extends StatelessWidget { required this.onRun, this.showCta = false, this.canInsertPrompt = true, + this.projectId, + this.worktreePath, }); + /// The facts as of open time. + /// + /// Used only as a FALLBACK. This sheet used to be a `StatelessWidget` handed a + /// `PrStatus` computed by its caller, so it painted whatever was true when it + /// opened and never looked again — which became a real defect the moment the + /// header started hosting the "Lands in" picker: change where a worktree lands + /// from inside this sheet and it would keep showing the old +/- numbers until + /// you closed and reopened it. Now it re-derives from `reposProvider` whenever + /// [worktreePath] identifies a worktree the snapshot still knows. final PrStatus status; final PullRequest? pr; final void Function(PrRemedy remedy) onRun; + /// Identity, so the sheet can re-derive rather than re-use. Optional: some + /// surfaces (a brand-new worktree the snapshot has not seen) have no identity + /// to resolve, and those keep the passed-in [status]. + final String? projectId; + final String? worktreePath; + /// Pin the lifecycle CTA at the bottom (mobile, where nothing else carries it). final bool showCta; @@ -101,7 +128,17 @@ class PrDetailBody extends StatelessWidget { final bool canInsertPrompt; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + // Re-derive from the single source of truth. `locateWorktree` returns null for + // a path the snapshot does not carry (removed, or not yet seen), in which case + // the open-time values are the best we have and the sheet stays put. + final projectId = this.projectId; + final at = worktreePath == null + ? null + : ref.watch(reposProvider).locateWorktree(worktreePath); + final status = at == null ? this.status : prStatusFor(at); + final pr = at == null ? this.pr : at.worktree.pr; + final worktree = at?.worktree; final checks = sortPrChecks(pr?.checks ?? const []); // With a pinned CTA (mobile) the loud fact is already the headline *and* the // button, so listing it again below would say the same thing three times. @@ -227,17 +264,53 @@ class PrDetailBody extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SheetHeader(title: status.identity), + // Home 2: `branch ≫ target`. + // + // This is the only place head and target appear together, and with a PR + // it is the only place the BRANCH appears at all — `status.identity` is + // `#` once a PR exists (see `prStatusFor`), so without this line + // a PR sheet never names the branch it is about. + // + // A header subtitle rather than a `Needs you` row on purpose: putting it + // in the fact list would claim something is wrong, and this is merely + // true. The picker opens from the target half. + if (worktree != null && + !worktree.isPrimary && + worktree.branch != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, kSpace8), + child: LandsInLine( + sourceBranch: pr != null ? worktree.branch : null, + targetBranch: worktree.targetBranch, + targetResolved: worktree.targetResolved, + onTap: projectId == null + ? null + : () => showLandsInPicker( + context, + ref, + projectId: projectId, + worktree: worktree, + sheet: showCta, + ), + trailing: worktree.showsDiff + ? DiffChip( + insertions: worktree.insertions, + deletions: worktree.deletions, + ) + : null, + ), + ), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showCta) _Hero(status: status), - if (pr != null && pr!.title.isNotEmpty) + if (pr != null && pr.title.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: kSpace10), child: Text( - pr!.title, + pr.title, style: Theme.of(context).textTheme.bodyMedium, ), ), @@ -278,7 +351,10 @@ class PrDetailBody extends StatelessWidget { child: _OpenOnForgeButton( identity: status.identity, url: pr!.url, - onPressed: () => _open(context), + // Open the LIVE url (from the re-derived `pr`), not the + // stale widget field — `_open` takes it explicitly so a + // closed/removed PR cannot null-assert here. + onPressed: () => _open(context, pr.url), ), ), ], @@ -290,8 +366,7 @@ class PrDetailBody extends StatelessWidget { ); } - void _open(BuildContext context) { - final url = pr!.url; + void _open(BuildContext context, String url) { Navigator.of(context).maybePop(); openPrUrl(context, url); } @@ -710,6 +785,11 @@ List buildPrActionMenu( WidgetRef ref, { required PrStatus status, required void Function(PrRemedy remedy) onRun, + + /// Identity for the "Lands in" entry. Omitted where the surface cannot name a + /// worktree, in which case the group is simply absent. + String? projectId, + Worktree? worktree, }) { final hasPr = status.hasPr; final ended = status.isEnded; @@ -771,6 +851,51 @@ List buildPrActionMenu( reason: _whyNot(action, status, ended: ended), onRun: onRun, ), + // Home 1: per-worktree config, at the BOTTOM, below a divider. + // + // The two groups above are "what to do next"; where a branch lands is not a + // next step, so it belongs in neither. Bottom-of-menu is the conventional + // home for per-object settings and it is where the eye stops looking for + // actions. It prints its current value inline, so opening this menu for any + // other reason answers "where does this go?" for free — which is the whole + // disclosure budget this feature needs. + if (projectId != null && + worktree != null && + !worktree.isPrimary && + worktree.branch != null) ...[ + const Divider(height: 1), + const _MenuGroup('This worktree'), + MenuItemButton( + leadingIcon: const Icon(kLandsInIcon, size: 16), + onPressed: () => showLandsInPicker( + context, + ref, + projectId: projectId, + worktree: worktree, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Lands in', style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(width: kSpace10), + // Flexible + ellipsis: a long target branch must truncate, not blow + // the menu row past the screen edge (matches `LandsInLine`). + Flexible( + child: Text( + worktree.targetBranch ?? 'not set', + overflow: TextOverflow.ellipsis, + softWrap: false, + style: Theme.of(context).textTheme.labelSmall?.mono.copyWith( + color: worktree.targetUnresolved + ? Theme.of(context).colorScheme.statusWarningText + : Theme.of(context).colorScheme.outline, + ), + ), + ), + ], + ), + ), + ], ]; } diff --git a/app/lib/ui/widgets/pr_signals.dart b/app/lib/ui/widgets/pr_signals.dart index 2ae7dbe4..a2270862 100644 --- a/app/lib/ui/widgets/pr_signals.dart +++ b/app/lib/ui/widgets/pr_signals.dart @@ -300,7 +300,11 @@ String _plural(int n, String singular, [String? plural]) => /// base's count is the **primary checkout's** own `behindCount`, which the server /// derives as `HEAD..@{upstream}` there — that is precisely "main is N behind". class PrResidue { - const PrResidue({this.sessions = 0, this.baseBranch, this.baseBehind = 0}); + const PrResidue({ + this.sessions = 0, + this.targetBranch, + this.targetBehind = 0, + }); /// Sessions bound to the worktree, closed ones excluded by the server. A /// wrap-up or discard closes every one of them, which is what the fact names @@ -310,10 +314,10 @@ class PrResidue { /// The branch checked out in the primary checkout, for the fact's wording. /// Null when there is no primary worktree in the snapshot, or it is detached. - final String? baseBranch; + final String? targetBranch; /// How far that branch trails its upstream. - final int baseBehind; + final int targetBehind; } /// Derive the status of a worktree. @@ -340,6 +344,16 @@ PrStatus prStatus({ int commitsAhead = 0, int commitsBehind = 0, bool isPrimary = false, + + /// The branch this worktree's work lands in, and whether it resolved. + /// + /// Only used to report an unresolvable one. The diff counts are NOT re-derived + /// here — the server already measured them against this target. + String? targetBranch, + bool targetResolved = true, + + /// The target this replaced, when makit moved it automatically (rule 4). + String? retargetedFrom, PrResidue residue = const PrResidue(), }) { final state = pr?.state.toUpperCase(); @@ -385,10 +399,10 @@ PrStatus prStatus({ '${_plural(residue.sessions, 'session')} to close', PrTone.quiet, ), - if (residue.baseBranch != null && residue.baseBehind > 0) + if (residue.targetBranch != null && residue.targetBehind > 0) PrSignal( - '${residue.baseBranch} is ' - '${_plural(residue.baseBehind, 'commit')} behind', + '${residue.targetBranch} is ' + '${_plural(residue.targetBehind, 'commit')} behind', PrTone.quiet, ), ], @@ -412,6 +426,27 @@ PrStatus prStatus({ final signals = []; + // FIRST, ahead of everything else: when the target cannot be resolved there is + // nowhere to land, and every count below is measured against a ref that is not + // there — so this is not merely urgent, it invalidates its neighbours. + // + // It exists because suppressing the +/- pill (see `Worktree.showsDiff`) is only + // half the job: hiding a misleading partial count leaves the row looking exactly + // like a clean worktree, so the user has committed work and the UI says nothing. + // + // Deliberately carries NO remedy. Opening a branch picker is navigation, and + // this remedy system is built for server ops and canned prompts — the fix has + // three homes already (the worktree-actions menu, the `Ship it` caret menu, and + // the detail sheet's own header line, which sits directly above this fact). + if (targetBranch != null && !targetResolved) { + signals.add( + PrSignal( + 'target $targetBranch is gone — nowhere to land', + PrTone.blocking, + ), + ); + } + if (uncommittedFiles > 0) { signals.add( PrSignal( @@ -562,6 +597,22 @@ PrStatus prStatus({ } } + // Rule 4 / B7's announcement: makit moved this worktree's target because the + // branch it was aiming at vanished, or a live pull request disagreed with us. + // Not optional — the diff and the next pull request just changed destination, + // and letting that happen invisibly is how someone opens a PR against the + // wrong branch. + // + // Added LAST, like `still a draft` above, precisely so it cannot take the loud + // slot from an actionable fact. Real-app QA caught that: inserted earlier it + // became the composer strip's headline and pushed `1 commit unpushed` into + // `+1 more`, letting an informational note crowd out what you can act on. + if (retargetedFrom != null && targetBranch != null) { + signals.add( + PrSignal('was $retargetedFrom, now $targetBranch', PrTone.quiet), + ); + } + final loud = signals.first; // A call to action that *has* an action must not look inert. Several quiet // facts carry a remedy — `still a draft`, `ready to merge`, and a draft's own @@ -599,7 +650,13 @@ PrStatus prStatus({ // CTA-level only, never a signal's remedy: two facts each offering "Ship it" // in the detail list would be two buttons doing the same whole-branch job and // neither clearing the row it sat on. The facts keep `Commit & push`/`Push`. - final canShipIt = pr == null && !isPrimary && branch != null; + // + // Gated on `targetResolved`: when the target is set but gone there is nowhere + // to land, and `gh pr create` would fall back to the CLI's default base — + // opening the PR against the WRONG branch. The `nowhere to land` fact already + // says so; the fix is to pick a target (the header/menu picker), not to ship. + final canShipIt = + pr == null && !isPrimary && branch != null && targetResolved; final cta = canShipIt ? PrCta( 'Ship it', @@ -611,11 +668,13 @@ PrStatus prStatus({ : _ctaFor( loud, tone: ctaTone, - // Unreachable while [canShipIt] covers every PR-less secondary branch, - // and kept as the honest fallback rather than deleted: the standing - // offer belongs to `_ctaFor`, and inlining its condition here would put - // the same rule in two places. - canCreatePr: pr == null && !isPrimary, + // Unreachable while [canShipIt] covers every PR-less secondary branch + // with a resolvable target, and kept as the honest fallback rather than + // deleted: the standing offer belongs to `_ctaFor`, and inlining its + // condition here would put the same rule in two places. Also gated on + // `targetResolved` so an unresolvable target cannot offer "Create PR" + // (the same wrong-base trap as Ship it) — it falls to "Ask the agent". + canCreatePr: pr == null && !isPrimary && targetResolved, branch: branch, ); @@ -776,6 +835,9 @@ PrStatus prStatusFor( commitsAhead: w.aheadCount, commitsBehind: w.behindCount, isPrimary: w.isPrimary, + targetBranch: w.targetBranch, + targetResolved: w.targetResolved, + retargetedFrom: w.retargetedFrom, // Residue is what a wrap-up or discard would *take with it*, and the primary // checkout is never removed (see the ending branch: it gets no direct op at // all). So it has no residue to report — its sessions are not going anywhere, @@ -784,8 +846,13 @@ PrStatus prStatusFor( ? const PrResidue() : PrResidue( sessions: w.sessionIds.length, - baseBranch: primary?.branch, - baseBehind: primary?.behindCount ?? 0, + // NB: `PrResidue.targetBranch` is NOT the same concept as the + // `prStatus(targetBranch:)` argument above. That one is THIS + // worktree's own landing target (drives the "nowhere to land" + // signal); this is the PRIMARY checkout's branch, named only so the + // "
is N behind" residue fact reads naturally. + targetBranch: primary?.branch, + targetBehind: primary?.behindCount ?? 0, ), ); } diff --git a/app/lib/ui/widgets/wrap_up.dart b/app/lib/ui/widgets/wrap_up.dart index 9745f9f1..dcfe56e6 100644 --- a/app/lib/ui/widgets/wrap_up.dart +++ b/app/lib/ui/widgets/wrap_up.dart @@ -43,7 +43,7 @@ class PrOpTarget { const PrOpTarget({ required this.projectId, required this.worktreePath, - this.baseBranch, + this.targetBranch, this.expectBranch, }); @@ -51,7 +51,7 @@ class PrOpTarget { final String worktreePath; /// The PR's `baseRefName`, for wrap up's fast-forward leg. - final String? baseBranch; + final String? targetBranch; /// The branch the confirm dialog named. The server resolves the branch again /// when it runs, so without this the user could confirm "delete feat/x" and @@ -78,7 +78,7 @@ final prOpRunnerProvider = Provider( final report = await store.wrapUpWorktree( target.projectId, target.worktreePath, - baseBranch: target.baseBranch, + targetBranch: target.targetBranch, expectBranch: target.expectBranch, ); return PrOpOutcome(report.summary, detail: report.detail); @@ -193,7 +193,7 @@ Future runPrRemedy( PrOpTarget( projectId: projectId, worktreePath: worktreePath, - baseBranch: pr?.baseRefName, + targetBranch: pr?.baseRefName, expectBranch: branch, ), ); diff --git a/app/test/desktop/desktop_sidebar_test.dart b/app/test/desktop/desktop_sidebar_test.dart index ca9894a4..fac01c41 100644 --- a/app/test/desktop/desktop_sidebar_test.dart +++ b/app/test/desktop/desktop_sidebar_test.dart @@ -87,6 +87,31 @@ class _FakeStore extends StoreController { renames.add((path: worktreePath, name: newName)); if (fail) throw Exception('nope'); } + + final List<({String path, String target})> retargets = []; + + @override + Future> targetCandidates( + String projectId, + String worktreePath, + ) async => const [ + TargetCandidate( + branch: 'main', + group: TargetCandidateGroup.defaultBranch, + onRemote: true, + isSelf: false, + ), + ]; + + @override + Future setWorktreeTarget( + String projectId, + String worktreePath, + String target, + ) async { + retargets.add((path: worktreePath, target: target)); + if (fail) throw Exception('nope'); + } } RepoInfo _repo( @@ -115,6 +140,7 @@ Worktree _worktree( int deletions = 0, int filesChanged = 0, List sessionIds = const [], + String? targetBranch, PullRequest? pr, }) => Worktree( id: id, @@ -125,6 +151,7 @@ Worktree _worktree( deletions: deletions, filesChanged: filesChanged, sessionIds: sessionIds, + targetBranch: targetBranch, pr: pr, ); @@ -910,6 +937,143 @@ void main() { expect(store.renames, [(path: '/tmp/wt/wt-feat', name: 'feat/renamed')]); }); + group('lands in (⋯ menu)', () { + testWidgets('sits between Rename and Delete showing its current target', ( + tester, + ) async { + await _pump( + tester, + repos: [ + _repo( + 'p1', + 'alpha', + worktrees: [ + _worktree( + 'wt-feat', + branch: 'feat/login', + targetBranch: 'main', + sessionIds: ['s1'], + ), + ], + ), + ], + sessions: [_session('s1', 'p1', 'work', 'pi')], + ); + + await _openWorktreeMenu(tester, 'feat/login'); + + final item = find.widgetWithText(PopupMenuItem, 'Lands in'); + expect(item, findsOneWidget); + // The current target prints inline: this ⋯ menu replaced the diff pill on + // hover, so the pill is not glanceable while the menu is open. + expect( + find.descendant(of: item, matching: find.text('main')), + findsOneWidget, + ); + + // Order: Rename, then Lands in, then Delete. + final renameY = tester.getTopLeft(find.text('Rename branch')).dy; + final landsY = tester.getTopLeft(find.text('Lands in')).dy; + final deleteY = tester.getTopLeft(find.text('Delete worktree')).dy; + expect(renameY, lessThan(landsY)); + expect(landsY, lessThan(deleteY)); + }); + + testWidgets('is disabled on the primary worktree', (tester) async { + await _pump( + tester, + repos: [ + _repo( + 'p1', + 'alpha', + worktrees: [_worktree('wt-main', branch: 'main', isPrimary: true)], + ), + ], + sessions: const [], + ); + + await _openWorktreeMenu(tester, 'main'); + + final item = tester.widget>( + find.widgetWithText(PopupMenuItem, 'Lands in'), + ); + expect(item.enabled, isFalse); + }); + + testWidgets('stays enabled with an open PR while Rename is disabled', ( + tester, + ) async { + // The deliberate asymmetry: `gh pr edit --base` retargets a live PR, but + // a rename would orphan its head. + await _pump( + tester, + repos: [ + _repo( + 'p1', + 'alpha', + worktrees: [ + _worktree( + 'wt-pr', + branch: 'feat/has-pr', + targetBranch: 'main', + sessionIds: ['s1'], + pr: const PullRequest( + number: 7, + url: '', + state: 'OPEN', + title: 'x', + isDraft: false, + ), + ), + ], + ), + ], + sessions: [_session('s1', 'p1', 'work', 'pi')], + ); + + await _openWorktreeMenu(tester, 'feat/has-pr'); + + final rename = tester.widget>( + find.widgetWithText(PopupMenuItem, 'Rename branch'), + ); + final landsIn = tester.widget>( + find.widgetWithText(PopupMenuItem, 'Lands in'), + ); + expect(rename.enabled, isFalse); + expect(landsIn.enabled, isTrue); + }); + + testWidgets('selecting it opens the picker (dialog, not a sheet)', ( + tester, + ) async { + await _pumpWithStore( + tester, + repos: [ + _repo( + 'p1', + 'alpha', + worktrees: [ + _worktree( + 'wt-feat', + branch: 'feat/login', + targetBranch: 'main', + sessionIds: ['s1'], + ), + ], + ), + ], + sessions: [_session('s1', 'p1', 'work', 'pi')], + ); + + await _openWorktreeMenu(tester, 'feat/login'); + await tester.tap(find.text('Lands in')); + await tester.pumpAndSettle(); + + // The picker fetched its candidate list and rendered a row for it. + expect(find.byKey(const Key('landsInCandidate-main')), findsOneWidget); + }); + }); + testWidgets('fold button collapses the sidebar via the provider', ( tester, ) async { diff --git a/app/test/desktop/keymap_scope_test.dart b/app/test/desktop/keymap_scope_test.dart index 5b7e5ab7..ce3def61 100644 --- a/app/test/desktop/keymap_scope_test.dart +++ b/app/test/desktop/keymap_scope_test.dart @@ -57,9 +57,9 @@ class _WtStore extends StoreController { @override Future<({String path, String? branch})> createWorktree( String projectId, { - String? baseBranch, + String? targetBranch, String? branchName, - }) async => (path: '/tmp/wt/created', branch: 'auto/$baseBranch'); + }) async => (path: '/tmp/wt/created', branch: 'auto/$targetBranch'); @override Future spawnSession( diff --git a/app/test/desktop/new_worktree_dialog_test.dart b/app/test/desktop/new_worktree_dialog_test.dart index 05fa0202..fb2b5d59 100644 --- a/app/test/desktop/new_worktree_dialog_test.dart +++ b/app/test/desktop/new_worktree_dialog_test.dart @@ -46,12 +46,12 @@ class _FakeStore extends StoreController { @override Future<({String path, String? branch})> createWorktree( String projectId, { - String? baseBranch, + String? targetBranch, String? branchName, }) async { - createdWorktreeBases.add(baseBranch); + createdWorktreeBases.add(targetBranch); createdWorktreeNames.add(branchName); - return (path: '/tmp/wt/new-$baseBranch', branch: 'auto/$baseBranch'); + return (path: '/tmp/wt/new-$targetBranch', branch: 'auto/$targetBranch'); } @override diff --git a/app/test/store/pull_request_model_test.dart b/app/test/store/pull_request_model_test.dart index 357381e0..db161768 100644 --- a/app/test/store/pull_request_model_test.dart +++ b/app/test/store/pull_request_model_test.dart @@ -123,29 +123,44 @@ void _wrapUpAndBaseRefTests() { test('decodes a full report', () { final r = WrapUpReport.fromJson({ 'branchDeleted': 'feat/x', - 'baseBranch': 'main', - 'baseUpdated': true, + 'targetBranch': 'main', + 'targetUpdated': true, }); expect(r.branchDeleted, 'feat/x'); - expect(r.baseBranch, 'main'); - expect(r.baseUpdated, isTrue); - expect(r.baseReason, isNull); + expect(r.targetBranch, 'main'); + expect(r.targetUpdated, isTrue); + expect(r.targetReason, isNull); expect(r.summary, 'Removed feat/x · main updated'); }); - test('says the base was left alone when it was not fast-forwardable', () { + test('still decodes the legacy base* keys', () { + // A server that predates the base->target rename. Without these aliases the + // report silently loses which branch was caught up and whether it moved, + // which turns "tidied and caught up" into "tidied, base untouched". final r = WrapUpReport.fromJson({ 'branchDeleted': 'feat/x', 'baseBranch': 'main', - 'baseUpdated': false, - 'baseReason': 'main has local commits that are not on origin/main', + 'baseUpdated': true, + 'baseReason': 'nope', + }); + expect(r.targetBranch, 'main'); + expect(r.targetUpdated, isTrue); + expect(r.targetReason, 'nope'); + }); + + test('says the base was left alone when it was not fast-forwardable', () { + final r = WrapUpReport.fromJson({ + 'branchDeleted': 'feat/x', + 'targetBranch': 'main', + 'targetUpdated': false, + 'targetReason': 'main has local commits that are not on origin/main', }); expect(r.summary, 'Removed feat/x · main unchanged'); - expect(r.baseReason, contains('local commits')); + expect(r.targetReason, contains('local commits')); }); test('a detached worktree reports the removal without a branch', () { - final r = WrapUpReport.fromJson({'baseBranch': 'main'}); + final r = WrapUpReport.fromJson({'targetBranch': 'main'}); expect(r.summary, 'Worktree removed · main unchanged'); }); @@ -154,8 +169,8 @@ void _wrapUpAndBaseRefTests() { // failed, saying only "Removed feat/x" would claim the opposite of what // happened — and the user cannot retry, the worktree is already gone. final r = WrapUpReport.fromJson({ - 'baseBranch': 'main', - 'baseUpdated': true, + 'targetBranch': 'main', + 'targetUpdated': true, 'branchReason': 'git branch -D feat/x failed: cannot lock ref', }); expect(r.branchDeleted, isNull); @@ -164,11 +179,11 @@ void _wrapUpAndBaseRefTests() { expect(r.detail, contains('cannot lock ref')); }); - test('combines both reasons when the base was skipped too', () { + test('combines both reasons when the target was skipped too', () { final r = WrapUpReport.fromJson({ - 'baseBranch': 'main', - 'baseUpdated': false, - 'baseReason': 'main has local commits', + 'targetBranch': 'main', + 'targetUpdated': false, + 'targetReason': 'main has local commits', 'branchReason': 'cannot lock ref', }); expect(r.detail, contains('cannot lock ref')); @@ -180,7 +195,7 @@ void _wrapUpAndBaseRefTests() { // still be able to say something. final r = WrapUpReport.fromJson(const {}); expect(r.summary, 'Worktree removed'); - expect(r.baseUpdated, isFalse); + expect(r.targetUpdated, isFalse); }); }); } diff --git a/app/test/store/target_candidate_test.dart b/app/test/store/target_candidate_test.dart new file mode 100644 index 00000000..a0c73118 --- /dev/null +++ b/app/test/store/target_candidate_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/store/models.dart'; + +void main() { + test('parses a full candidate', () { + final c = TargetCandidate.fromJson({ + 'branch': 'feat/parent', + 'group': 'forkedFrom', + 'onRemote': true, + 'isSelf': false, + 'insertions': 96, + 'deletions': 12, + })!; + expect(c.branch, 'feat/parent'); + expect(c.group, TargetCandidateGroup.forkedFrom); + expect(c.onRemote, isTrue); + expect(c.isSelf, isFalse); + expect(c.insertions, 96); + expect(c.deletions, 12); + expect(c.hasPreview, isTrue); + }); + + test('a candidate with no preview reports hasPreview false', () { + final c = TargetCandidate.fromJson({ + 'branch': 'zz-other', + 'group': 'other', + 'onRemote': true, + 'isSelf': false, + })!; + expect(c.hasPreview, isFalse); + expect(c.insertions, isNull); + expect(c.deletions, isNull); + }); + + test('a half-filled preview is not a preview', () { + // The picker force-unwraps BOTH `insertions!` and `deletions!` behind + // `hasPreview`, so one count alone must NOT report a preview or the picker + // would throw on the missing half. + final onlyIns = TargetCandidate.fromJson({ + 'branch': 'zz-other', + 'group': 'other', + 'onRemote': true, + 'isSelf': false, + 'insertions': 5, + })!; + expect(onlyIns.hasPreview, isFalse); + final onlyDel = TargetCandidate.fromJson({ + 'branch': 'zz-other', + 'group': 'other', + 'onRemote': true, + 'isSelf': false, + 'deletions': 5, + })!; + expect(onlyDel.hasPreview, isFalse); + }); + + test('an unknown group falls back to other rather than throwing', () { + // Forward compatibility: a newer server may add a group this build predates. + final c = TargetCandidate.fromJson({ + 'branch': 'x', + 'group': 'somethingNew', + 'onRemote': true, + 'isSelf': false, + })!; + expect(c.group, TargetCandidateGroup.other); + }); + + test('a candidate without a branch is rejected', () { + expect(TargetCandidate.fromJson({'group': 'other'}), isNull); + }); + + test('selectable is false for self and for local-only branches', () { + TargetCandidate c({bool self = false, bool remote = true}) => + TargetCandidate.fromJson({ + 'branch': 'b', + 'group': 'other', + 'onRemote': remote, + 'isSelf': self, + })!; + expect(c().selectable, isTrue); + expect(c(self: true).selectable, isFalse); + // A PR base must exist on the remote, so an unpushed branch is refused — + // listed with a reason rather than accepted and rejected later by `gh`. + expect(c(remote: false).selectable, isFalse); + }); + + test('blockedReason explains each refusal in the user\'s terms', () { + TargetCandidate c({bool self = false, bool remote = true}) => + TargetCandidate.fromJson({ + 'branch': 'b', + 'group': 'other', + 'onRemote': remote, + 'isSelf': self, + })!; + expect(c().blockedReason, isNull); + expect(c(self: true).blockedReason, 'this worktree'); + expect(c(remote: false).blockedReason, 'not pushed yet'); + }); + + test('groupLabel names each section the way the picker shows it', () { + expect(TargetCandidateGroup.forkedFrom.label, 'Forked from'); + expect(TargetCandidateGroup.defaultBranch.label, 'Repo default'); + expect(TargetCandidateGroup.worktree.label, 'Other worktrees'); + expect(TargetCandidateGroup.other.label, 'All branches'); + }); +} diff --git a/app/test/store/worktree_target_test.dart b/app/test/store/worktree_target_test.dart new file mode 100644 index 00000000..f539fcfc --- /dev/null +++ b/app/test/store/worktree_target_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/store/models.dart'; + +/// The target branch and its resolution flag, mirrored from `WorktreeDTO`. +/// +/// `showsDiff` is the consumer-facing rule the server's `targetResolved` exists +/// for: when the target cannot be resolved the numbers are a working-tree-only +/// figure, so rendering them would claim a committed delta we never measured. +/// The failure mode is not a zero but a *plausible small* count, which is why +/// suppression has to be explicit rather than left to `hasChanges`. +Map wtJson(Map extra) => { + 'id': '/wt/child', + 'path': '/wt/child', + 'branch': 'feat/child', + 'isPrimary': false, + 'insertions': 3, + 'deletions': 1, + 'filesChanged': 2, + 'sessionIds': [], + ...extra, +}; + +void main() { + test('parses targetBranch and targetResolved', () { + final w = Worktree.fromJson( + wtJson({'targetBranch': 'feat/parent', 'targetResolved': true}), + )!; + expect(w.targetBranch, 'feat/parent'); + expect(w.targetResolved, isTrue); + }); + + test('a null targetBranch is preserved (primary / detached)', () { + final w = Worktree.fromJson( + wtJson({'targetBranch': null, 'targetResolved': true, 'isPrimary': true}), + )!; + expect(w.targetBranch, isNull); + }); + + test('targetResolved defaults to true when the server omits it', () { + // Forward compatibility: an older server sends neither field. Defaulting to + // "resolved" keeps today's rendering rather than blanking every pill. + final w = Worktree.fromJson(wtJson({}))!; + expect(w.targetBranch, isNull); + expect(w.targetResolved, isTrue); + expect(w.showsDiff, isTrue); + }); + + test('showsDiff is false when a target exists but did not resolve', () { + final w = Worktree.fromJson( + wtJson({'targetBranch': 'feat/gone', 'targetResolved': false}), + )!; + expect(w.hasChanges, isTrue, reason: 'the raw numbers are still non-zero'); + expect( + w.showsDiff, + isFalse, + reason: + 'a partial count must not be rendered as if it were the full diff', + ); + }); + + test('showsDiff is true when there is no target to resolve', () { + // The primary checkout legitimately reports working-tree-only numbers. + final w = Worktree.fromJson( + wtJson({'targetBranch': null, 'targetResolved': true}), + )!; + expect(w.showsDiff, isTrue); + }); + + test('showsDiff is false when there is nothing to show', () { + final w = Worktree.fromJson( + wtJson({ + 'insertions': 0, + 'deletions': 0, + 'filesChanged': 0, + 'targetBranch': 'main', + 'targetResolved': true, + }), + )!; + expect(w.showsDiff, isFalse); + }); + + test('targetUnresolved names the state the UI must explain', () { + final broken = Worktree.fromJson( + wtJson({'targetBranch': 'feat/gone', 'targetResolved': false}), + )!; + final fine = Worktree.fromJson( + wtJson({'targetBranch': 'main', 'targetResolved': true}), + )!; + expect(broken.targetUnresolved, isTrue); + expect(fine.targetUnresolved, isFalse); + }); +} diff --git a/app/test/ui/home/repo_card_test.dart b/app/test/ui/home/repo_card_test.dart index 684995b4..20510bd7 100644 --- a/app/test/ui/home/repo_card_test.dart +++ b/app/test/ui/home/repo_card_test.dart @@ -65,10 +65,10 @@ class _FakeStore extends StoreController { @override Future<({String path, String? branch})> createWorktree( String projectId, { - String? baseBranch, + String? targetBranch, String? branchName, }) async { - createdFrom.add(baseBranch); + createdFrom.add(targetBranch); return (path: '/tmp/demo-wt', branch: 'forked'); } diff --git a/app/test/ui/home/worktree_actions_test.dart b/app/test/ui/home/worktree_actions_test.dart index 3b1b04e9..7591117f 100644 --- a/app/test/ui/home/worktree_actions_test.dart +++ b/app/test/ui/home/worktree_actions_test.dart @@ -37,10 +37,10 @@ class _FakeStore extends StoreController { @override Future<({String path, String? branch})> createWorktree( String projectId, { - String? baseBranch, + String? targetBranch, String? branchName, }) async { - createdFrom.add(baseBranch); + createdFrom.add(targetBranch); return (path: '/tmp/demo/.wt/fresh', branch: 'fresh-branch'); } @@ -73,11 +73,36 @@ class _FakeStore extends StoreController { @override Future> listOpenPrs(String projectId) async => const []; + + // The picker reads candidates on open; return a small set so tapping + // "Lands in" does not blow up when a test drives the picker. + final List<(String, String)> retargeted = []; + + @override + Future> targetCandidates( + String projectId, + String worktreePath, + ) async => const [ + TargetCandidate( + branch: 'main', + group: TargetCandidateGroup.defaultBranch, + onRemote: true, + isSelf: false, + ), + ]; + + @override + Future setWorktreeTarget( + String projectId, + String worktreePath, + String targetBranch, + ) async => retargeted.add((worktreePath, targetBranch)); } Worktree _wt({ String branch = 'add-login', bool isPrimary = false, + String? targetBranch, PullRequest? pr, List sessionIds = const [], }) => Worktree( @@ -89,6 +114,7 @@ Worktree _wt({ deletions: 0, filesChanged: 0, sessionIds: sessionIds, + targetBranch: targetBranch, pr: pr, ); @@ -186,6 +212,23 @@ void main() { expect(canRenameWorktree(w), isFalse); expect(canDeleteWorktree(w), isTrue); }); + + test('a feature branch can be retargeted; primary and detached cannot', () { + expect(canRetargetWorktree(_wt()), isTrue); + expect( + canRetargetWorktree(_wt(branch: 'main', isPrimary: true)), + isFalse, + ); + expect(canRetargetWorktree(_detached()), isFalse); + }); + + test('an open PR does NOT block retargeting (unlike rename)', () { + // The whole point of the asymmetry: `gh pr edit --base` retargets a live + // PR, whereas a rename would orphan its head. + final w = _wt(pr: _openPr()); + expect(canRenameWorktree(w), isFalse); + expect(canRetargetWorktree(w), isTrue); + }); }); group('worktree actions sheet', () { @@ -274,6 +317,103 @@ void main() { }); }); + group('lands in', () { + testWidgets('the sheet lists "Lands in" with the current target', ( + tester, + ) async { + final repo = _repo([_wt(targetBranch: 'main')]); + await _pump( + tester, + WorktreeRow( + repo: repo, + worktree: repo.worktrees.first, + sessions: const [], + ), + ); + + await tester.longPress(find.text('add-login')); + await tester.pumpAndSettle(); + + final tile = _tile(tester, 'Lands in'); + expect(tile.enabled, isTrue); + // Its subtitle states today's value so the row is not a mystery door. + expect((tile.subtitle! as Text).data, 'main'); + }); + + testWidgets('disabled with a reason on the primary checkout', ( + tester, + ) async { + final repo = _repo([_wt(branch: 'main', isPrimary: true)]); + await _pump( + tester, + WorktreeRow( + repo: repo, + worktree: repo.worktrees.first, + sessions: const [], + ), + ); + + await tester.longPress(find.text('main')); + await tester.pumpAndSettle(); + + final tile = _tile(tester, 'Lands in'); + expect(tile.enabled, isFalse); + expect( + (tile.subtitle! as Text).data, + 'This is where branches land, not one that lands', + ); + }); + + testWidgets('stays enabled with an open PR while Rename is disabled', ( + tester, + ) async { + // The asymmetry, exercised end to end in the sheet. + final repo = _repo([_wt(targetBranch: 'main', pr: _openPr())]); + await _pump( + tester, + WorktreeRow( + repo: repo, + worktree: repo.worktrees.first, + sessions: const [], + ), + ); + + await tester.longPress(find.text('add-login')); + await tester.pumpAndSettle(); + + expect(_tile(tester, 'Rename branch').enabled, isFalse); + expect(_tile(tester, 'Lands in').enabled, isTrue); + }); + + testWidgets('tapping it opens the picker (bottom sheet on touch)', ( + tester, + ) async { + final repo = _repo([_wt(targetBranch: 'feat/parent')]); + final store = await _pump( + tester, + WorktreeRow( + repo: repo, + worktree: repo.worktrees.first, + sessions: const [], + ), + ); + + await tester.longPress(find.text('add-login')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Lands in')); + await tester.pumpAndSettle(); + + // The picker renders its own candidate row for the branch it fetched. + expect(find.byKey(const Key('landsInCandidate-main')), findsOneWidget); + + // Choosing a candidate that differs from the current target issues the + // retarget — the step that actually matters, not just that the picker drew. + await tester.tap(find.byKey(const Key('landsInCandidate-main'))); + await tester.pumpAndSettle(); + expect(store.retargeted, [('/tmp/demo/.wt/add-login', 'main')]); + }); + }); + group('new worktree', () { testWidgets('the card footer offers it', (tester) async { final repo = _repo([_wt(branch: 'main', isPrimary: true)]); diff --git a/app/test/ui/home/worktree_row_target_diff_test.dart b/app/test/ui/home/worktree_row_target_diff_test.dart new file mode 100644 index 00000000..02b81a94 --- /dev/null +++ b/app/test/ui/home/worktree_row_target_diff_test.dart @@ -0,0 +1,166 @@ +// The last link in the chain: server wire JSON -> `Worktree` -> RENDERED pixels. +// +// The server-side end-to-end test proves git -> diffStat -> WorktreeDTO -> wire +// frame (a stacked worktree drops from +23 to +3 after `worktree.setTarget`), and +// the model test proves wire JSON -> `Worktree.targetBranch`/`showsDiff`. Neither +// proves the row actually PAINTS the corrected number, which is the only part +// the user ever sees. This closes that gap by feeding the exact JSON shape the +// server emits into the real `WorktreeRow`. +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:makit/store/connection.dart'; +import 'package:makit/store/models.dart'; +import 'package:makit/store/secure_store.dart'; +import 'package:makit/ui/home/worktree_row.dart'; + +class _EmptyStorage implements SecureStore { + const _EmptyStorage(); + @override + Future read({required String key}) async => null; + @override + Future write({required String key, required String? value}) async {} + @override + Future delete({required String key}) async {} +} + +const _repo = RepoInfo( + id: 'p1', + name: 'demo', + path: '/tmp/demo', + pinned: false, + lastActivityAt: 0, + isGitRepo: true, + defaultBranch: 'main', + currentBranch: 'main', + worktrees: [], +); + +/// A worktree exactly as `WorktreeDTO` arrives on the wire. +Worktree fromWire({ + required int insertions, + required int deletions, + String? targetBranch, + bool targetResolved = true, +}) { + final w = Worktree.fromJson({ + 'id': '/wt/child', + 'path': '/wt/child', + 'branch': 'feat/child', + 'isPrimary': false, + 'targetBranch': targetBranch, + 'targetResolved': targetResolved, + 'insertions': insertions, + 'deletions': deletions, + 'filesChanged': 2, + 'uncommittedFiles': 0, + 'aheadCount': 1, + 'behindCount': 0, + 'committedAt': null, + 'pr': null, + 'sessionIds': [], + }); + expect(w, isNotNull, reason: 'the wire shape must decode'); + return w!; +} + +Future pumpRow(WidgetTester tester, Worktree worktree) async { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold( + body: ListView( + children: [ + WorktreeRow(repo: _repo, worktree: worktree, sessions: const []), + ], + ), + ), + ), + ], + ); + final container = ProviderContainer( + overrides: [ + connectionControllerProvider.overrideWith( + (ref) => ConnectionController(const _EmptyStorage()), + ), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('renders the target-relative diff, not the inflated one', ( + tester, + ) async { + // What the server now sends for a worktree targeting its parent. + await pumpRow( + tester, + fromWire(insertions: 3, deletions: 1, targetBranch: 'feat/parent'), + ); + expect(find.text('+3'), findsOneWidget); + expect(find.text('\u22121'), findsOneWidget); + // The pre-fix figure (parent's work counted as the child's) must be absent. + expect(find.text('+23'), findsNothing); + }); + + testWidgets('suppresses the pill when the target could not be resolved', ( + tester, + ) async { + // R7/B5: the numbers here are working-tree-only, so painting them would + // assert a committed delta that was never measured. The failure mode is a + // plausible SMALL count, not a zero, which is why this must be suppressed + // rather than left to `hasChanges`. + await pumpRow( + tester, + fromWire( + insertions: 3, + deletions: 1, + targetBranch: 'feat/deleted', + targetResolved: false, + ), + ); + expect(find.text('+3'), findsNothing); + expect(find.text('\u22121'), findsNothing); + }); + + testWidgets('still renders working-tree numbers when there is no target', ( + tester, + ) async { + // The primary checkout and detached worktrees have no target; their numbers + // legitimately mean "uncommitted" and must not be suppressed. + await pumpRow( + tester, + fromWire(insertions: 3, deletions: 1, targetBranch: null), + ); + expect(find.text('+3'), findsOneWidget); + }); + + testWidgets('an older server (no target fields) keeps rendering', ( + tester, + ) async { + // Forward compatibility: `targetResolved` defaults to true, so a stale + // server does not blank every pill in the list. + final w = Worktree.fromJson({ + 'id': '/wt/child', + 'path': '/wt/child', + 'branch': 'feat/child', + 'isPrimary': false, + 'insertions': 7, + 'deletions': 0, + 'filesChanged': 1, + 'sessionIds': [], + })!; + await pumpRow(tester, w); + expect(find.text('+7'), findsOneWidget); + }); +} diff --git a/app/test/ui/widgets/pr_detail_live_test.dart b/app/test/ui/widgets/pr_detail_live_test.dart new file mode 100644 index 00000000..d27c3785 --- /dev/null +++ b/app/test/ui/widgets/pr_detail_live_test.dart @@ -0,0 +1,252 @@ +// R5: the detail sheet must re-derive its facts, not freeze them at open time. +// +// `PrDetailBody` used to be a `StatelessWidget` handed a `PrStatus` computed by +// its caller. That was stale-by-construction the moment the sheet header started +// hosting the "Lands in" picker: change where a worktree lands from inside the +// sheet and it kept painting the previous +/- numbers until you closed and +// reopened it — on the one screen where the user had just acted. +// +// These tests pump the sheet, then push a NEW repos snapshot underneath it and +// assert the open sheet follows. +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:makit/store/models.dart'; +import 'package:makit/store/store.dart'; +import 'package:makit/ui/widgets/pr_detail.dart'; +import 'package:makit/ui/widgets/pr_signals.dart'; + +Worktree wt({ + required int insertions, + String? targetBranch = 'feat/parent', + bool targetResolved = true, + String? retargetedFrom, +}) => Worktree( + id: '/wt/child', + path: '/wt/child', + branch: 'feat/child', + isPrimary: false, + insertions: insertions, + deletions: 0, + filesChanged: 1, + sessionIds: const [], + targetBranch: targetBranch, + targetResolved: targetResolved, + retargetedFrom: retargetedFrom, +); + +RepoInfo repoWith(Worktree w) => RepoInfo( + id: 'p1', + name: 'demo', + path: '/tmp/demo', + pinned: false, + lastActivityAt: 0, + isGitRepo: true, + defaultBranch: 'main', + currentBranch: 'main', + worktrees: [w], +); + +/// Pump the sheet body against a mutable repos state. +Future pumpSheet( + WidgetTester tester, + ValueNotifier repos, { + String? worktreePath = '/wt/child', +}) async { + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: repos, + builder: (context, value, _) => ProviderScope( + overrides: [reposProvider.overrideWithValue(value)], + child: MaterialApp( + home: Scaffold( + body: PrDetailBody( + // Deliberately WRONG open-time facts, so anything the sheet paints + // from them instead of from the snapshot is visible. + status: prStatus(pr: null, branch: 'feat/child'), + pr: null, + onRun: (_) {}, + projectId: 'p1', + worktreePath: worktreePath, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('an open sheet picks up new diff numbers from the snapshot', ( + tester, + ) async { + final repos = ValueNotifier(ReposState([repoWith(wt(insertions: 23))])); + addTearDown(repos.dispose); + await pumpSheet(tester, repos); + expect(find.text('+23'), findsOneWidget); + + // A retarget lands: the server recomputes and broadcasts. Nothing else + // happens — no reopen, no navigation, no user interaction. + repos.value = ReposState([repoWith(wt(insertions: 3))]); + await tester.pumpAndSettle(); + + expect(find.text('+3'), findsOneWidget); + expect( + find.text('+23'), + findsNothing, + reason: 'the sheet must not keep painting open-time facts', + ); + }); + + testWidgets('the header names the target it lands in', (tester) async { + final repos = ValueNotifier(ReposState([repoWith(wt(insertions: 3))])); + addTearDown(repos.dispose); + await pumpSheet(tester, repos); + expect(find.text('feat/parent'), findsOneWidget); + }); + + testWidgets('an open sheet follows a target change', (tester) async { + final repos = ValueNotifier(ReposState([repoWith(wt(insertions: 3))])); + addTearDown(repos.dispose); + await pumpSheet(tester, repos); + expect(find.text('feat/parent'), findsOneWidget); + + repos.value = ReposState([ + repoWith(wt(insertions: 23, targetBranch: 'main')), + ]); + await tester.pumpAndSettle(); + expect(find.text('main'), findsOneWidget); + expect(find.text('feat/parent'), findsNothing); + }); + + testWidgets('an unresolvable target suppresses the diff and says why', ( + tester, + ) async { + final repos = ValueNotifier( + ReposState([repoWith(wt(insertions: 3, targetResolved: false))]), + ); + addTearDown(repos.dispose); + await pumpSheet(tester, repos); + // Step 8: the state is SAID, not merely hidden — suppression alone leaves the + // row indistinguishable from a clean worktree. + expect(find.textContaining('nowhere to land'), findsOneWidget); + expect(find.text('+3'), findsNothing); + }); + + testWidgets('an automatic retarget is announced in the sheet', ( + tester, + ) async { + // Rule 4 / B7 chose "fall back to the default, but say so". The saying-so has + // to actually reach a surface, or the fallback is silent after all — which is + // the failure mode the rule exists to prevent. + final repos = ValueNotifier( + ReposState([ + repoWith( + wt( + insertions: 3, + targetBranch: 'main', + retargetedFrom: 'feat/parent', + ), + ), + ]), + ); + addTearDown(repos.dispose); + await pumpSheet(tester, repos); + expect(find.textContaining('was feat/parent'), findsOneWidget); + expect(find.textContaining('now main'), findsOneWidget); + }); + + testWidgets('the announcement goes away once the target is owned', ( + tester, + ) async { + final repos = ValueNotifier( + ReposState([ + repoWith( + wt( + insertions: 3, + targetBranch: 'main', + retargetedFrom: 'feat/parent', + ), + ), + ]), + ); + addTearDown(repos.dispose); + await pumpSheet(tester, repos); + expect(find.textContaining('was feat/parent'), findsOneWidget); + + // The user picks a target: the server clears the note, so the sheet must stop + // announcing without needing to be reopened. + repos.value = ReposState([ + repoWith(wt(insertions: 3, targetBranch: 'main')), + ]); + await tester.pumpAndSettle(); + expect(find.textContaining('was feat/parent'), findsNothing); + }); + + testWidgets( + 'a worktree the snapshot does not know keeps its open-time facts', + (tester) async { + // A brand-new worktree, or one just removed: there is nothing to re-derive + // from, so the sheet must degrade rather than blank itself. + final repos = ValueNotifier(ReposState([repoWith(wt(insertions: 3))])); + addTearDown(repos.dispose); + await pumpSheet(tester, repos, worktreePath: '/wt/unknown'); + expect(find.text('+3'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'a PR dropped from the snapshot is not shown from the stale open-time field', + (tester) async { + // Thread 1: when the snapshot KNOWS the worktree but its PR is now null + // (closed and dropped), the re-derived status correctly shows no PR — so + // the sheet must not keep painting the open-time PR. In particular the + // "Open … on GitHub" link must be gone: it dereferences the live url, and + // resurrecting it from the stale field is exactly what threw a null assert. + const openPr = PullRequest( + number: 7, + url: 'https://example.test/7', + state: 'OPEN', + title: 'the pr', + isDraft: false, + checkRollup: 'none', + unresolvedComments: 0, + checks: [], + ); + // The worktree in the snapshot has no PR (the `wt` helper leaves it null). + final repos = ValueNotifier(ReposState([repoWith(wt(insertions: 3))])); + addTearDown(repos.dispose); + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: repos, + builder: (context, value, _) => ProviderScope( + overrides: [reposProvider.overrideWithValue(value)], + child: MaterialApp( + home: Scaffold( + body: PrDetailBody( + // Open-time facts carry a live PR... + status: prStatus(pr: openPr, branch: 'feat/child'), + pr: openPr, + onRun: (_) {}, + projectId: 'p1', + worktreePath: '/wt/child', + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + // ...but the snapshot says there is no PR, so the link is gone. + expect( + find.textContaining('on GitHub'), + findsNothing, + reason: 'a worktree with no live PR must not resurrect the stale one', + ); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/app/test/ui/widgets/pr_signals_target_test.dart b/app/test/ui/widgets/pr_signals_target_test.dart new file mode 100644 index 00000000..7a793df9 --- /dev/null +++ b/app/test/ui/widgets/pr_signals_target_test.dart @@ -0,0 +1,195 @@ +// Step 8: an unresolvable target must be SAID, not merely hidden. +// +// Suppressing the +/- pill (which `Worktree.showsDiff` does) stops the app +// publishing a partial count that reads as "barely diverged". But suppression +// alone makes the row identical to a clean worktree: the user has committed work +// and the UI says nothing. This signal is what closes that gap. +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/store/models.dart'; +import 'package:makit/ui/widgets/pr_signals.dart'; + +void main() { + test('an automatic retarget is announced as a quiet fact', () { + // Rule 4: a target that vanished without a wrap-up falls back to the default, + // and the change must be SAID. A silent repoint moves this worktree's diff and + // its future pull request to a different destination. + final s = prStatus( + pr: null, + branch: 'feat/child', + targetBranch: 'main', + retargetedFrom: 'feat/parent', + ); + final fact = s.signals.firstWhere((x) => x.label.contains('feat/parent')); + expect(fact.label, contains('main')); + expect( + fact.tone, + PrTone.quiet, + reason: 'it is already fixed, so it informs rather than demands', + ); + expect(fact.remedy, isNull); + }); + + test('the announcement never outranks an actionable fact', () { + // Caught in the real app: added too early in the list it became the composer + // strip's headline and pushed `1 commit unpushed` into `+1 more` — an + // informational note crowding out the thing you can act on. `loud` is just + // `signals.first`, so position IS the priority. + final s = prStatus( + pr: null, + branch: 'feat/child', + uncommittedFiles: 1, + targetBranch: 'main', + retargetedFrom: 'feat/parent', + ); + expect(s.loud.label, contains('uncommitted')); + expect(s.signals.last.label, contains('was feat/parent')); + }); + + test('the announcement is always listed, even behind the all-clear', () { + // It never takes the loud slot — not from an actionable fact, and not from the + // all-clear either: "ready for a PR" is the more useful headline, and the + // announcement's required home is the sheet (which lists every signal) plus + // the strip's `+n more`. Simplest rule that satisfies "say so": always + // present, never promoted. + final s = prStatus( + pr: null, + branch: 'feat/child', + targetBranch: 'main', + retargetedFrom: 'feat/parent', + ); + expect(s.loud.label, isNot(contains('was feat/parent'))); + expect( + s.signals.any((x) => x.label.contains('was feat/parent, now main')), + isTrue, + ); + }); + + test('no announcement once the user has taken ownership', () { + final s = prStatus(pr: null, branch: 'feat/child', targetBranch: 'main'); + expect(s.signals.any((x) => x.label.contains('was ')), isFalse); + }); + + test( + 'an unresolvable target is still reported when nothing could be salvaged', + () { + final s = prStatus( + pr: null, + branch: 'feat/child', + uncommittedFiles: 2, + targetBranch: 'feat/parent', + targetResolved: false, + ); + expect(s.signals.first.label, contains('feat/parent')); + expect(s.signals.first.label, contains('gone')); + expect( + s.signals.first.tone, + PrTone.blocking, + reason: 'you cannot land anywhere, so it outranks uncommitted work', + ); + }, + ); + + test('it carries no remedy — the fix lives in the pickers, one line up', () { + final s = prStatus( + pr: null, + branch: 'feat/child', + targetBranch: 'feat/parent', + targetResolved: false, + ); + // Deliberately not a PrDirectOp: opening a picker is navigation, and the + // remedy plumbing is built for server ops and canned prompts. + expect(s.signals.first.remedy, isNull); + }); + + test('a resolved target adds no signal at all', () { + final s = prStatus( + pr: null, + branch: 'feat/child', + uncommittedFiles: 1, + targetBranch: 'feat/parent', + targetResolved: true, + ); + expect( + s.signals.any((x) => x.label.contains('feat/parent')), + isFalse, + reason: 'a working target is not news', + ); + }); + + test('no target means nothing to resolve, so no signal', () { + // The primary checkout and detached worktrees have no target. + final s = prStatus( + pr: null, + branch: 'main', + isPrimary: true, + targetBranch: null, + targetResolved: true, + ); + expect(s.signals.any((x) => x.label.contains('gone')), isFalse); + }); + + test('the signal survives alongside an open PR', () { + // Retargeting an open PR is legitimate, so a PR does not suppress this. + // Pass an actual OPEN PR (not null) so the test exercises the contract its + // name states, not merely the commitsAhead path. + const openPr = PullRequest( + number: 4, + url: 'https://example.test/4', + state: 'OPEN', + title: 'child', + isDraft: false, + checkRollup: 'none', + unresolvedComments: 0, + checks: [], + ); + final s = prStatus( + pr: openPr, + branch: 'feat/child', + commitsAhead: 3, + targetBranch: 'feat/parent', + targetResolved: false, + ); + expect(s.signals.first.label, contains('gone')); + expect(s.signals.length, greaterThan(1)); + }); + + // The CTA must not offer to open a PR when the target is gone: `gh pr create` + // would fall back to the CLI default and raise the PR against the WRONG base. + group('an unresolvable target gates the PR-creating CTA', () { + test('a PR-less branch with a resolvable target still offers Ship it', () { + final s = prStatus( + pr: null, + branch: 'feat/child', + targetBranch: 'feat/parent', + targetResolved: true, + ); + expect(s.cta.label, 'Ship it'); + expect(s.cta.remedy, isA()); + }); + + test('an unresolvable target withdraws Ship it (nowhere to land)', () { + final s = prStatus( + pr: null, + branch: 'feat/child', + targetBranch: 'feat/parent', + targetResolved: false, + ); + expect(s.cta.label, isNot('Ship it')); + // Falls back to the non-actionable offer rather than a wrong-base create. + expect(s.cta.label, 'Ask the agent'); + expect(s.cta.isIdle, isTrue); + }); + + test( + 'a null target (no destination set) still ships against the default', + () { + final s = prStatus( + pr: null, + branch: 'feat/child', + targetResolved: true, + ); + expect(s.cta.label, 'Ship it'); + }, + ); + }); +} diff --git a/app/test/ui/widgets/pr_signals_test.dart b/app/test/ui/widgets/pr_signals_test.dart index 304b59fa..b66d8204 100644 --- a/app/test/ui/widgets/pr_signals_test.dart +++ b/app/test/ui/widgets/pr_signals_test.dart @@ -826,8 +826,8 @@ void main() { uncommitted: 2, residue: const PrResidue( sessions: 1, - baseBranch: 'main', - baseBehind: 6, + targetBranch: 'main', + targetBehind: 6, ), ); expect(s.signals.map((x) => x.label), [ @@ -856,8 +856,8 @@ void main() { pr: _pr(rollup: 'pass'), residue: const PrResidue( sessions: 2, - baseBranch: 'main', - baseBehind: 6, + targetBranch: 'main', + targetBehind: 6, ), ); expect( diff --git a/app/tool/pr_bar_demo.dart b/app/tool/pr_bar_demo.dart index 944cc102..5d3ddd70 100644 --- a/app/tool/pr_bar_demo.dart +++ b/app/tool/pr_bar_demo.dart @@ -150,14 +150,22 @@ final _scenes = <_Scene>[ pr: _pr(state: 'MERGED', checks: _green), branch: 'fix/composer', uncommitted: 2, - residue: const PrResidue(sessions: 1, baseBranch: 'main', baseBehind: 6), + residue: const PrResidue( + sessions: 1, + targetBranch: 'main', + targetBehind: 6, + ), ), _Scene( 'closed without merging', pr: _pr(state: 'CLOSED', checks: _green), branch: 'spike/gauge', uncommitted: 1, - residue: const PrResidue(sessions: 2, baseBranch: 'main', baseBehind: 3), + residue: const PrResidue( + sessions: 2, + targetBranch: 'main', + targetBehind: 3, + ), ), _Scene( 'stale (GitHub quota)', @@ -205,7 +213,7 @@ void main() { 'DEMO: would run ${op.name}', detail: 'project=${target.projectId}\nworktree=${target.worktreePath}\n' - 'base=${target.baseBranch}\nexpectBranch=${target.expectBranch}', + 'target=${target.targetBranch}\nexpectBranch=${target.expectBranch}', ); }), ], diff --git a/docs/specs/2026-08-11-SPEC-51-target-branch.md b/docs/specs/2026-08-11-SPEC-51-target-branch.md new file mode 100644 index 00000000..857a5bb3 --- /dev/null +++ b/docs/specs/2026-08-11-SPEC-51-target-branch.md @@ -0,0 +1,166 @@ +# SPEC-51 — Target branch: where a worktree's work lands + +**Status:** Shipped (server + app) · **Priority:** P1 · **Branch:** `feat/base-branch` +**Number:** 48 and 49 were taken while this branch was in flight (status/activity, notice layer) +and 50 is claimed by profiles, hence 51. +**Depends on:** SPEC-38 (the PR next-step bar — `PrStatus`, `PrSignal`, the composer strip and the +detail sheet this reuses), SPEC-32 (the GitHub gateway and the `lastKnown` retain-on-throttle path +that makes PR-first resolution possible in the git-only phase), SPEC-11/SPEC-19 (the worktree +action menus this hangs a picker off). +**Design board:** [`mockups/base-branch.html`](../../mockups/base-branch.html) — the rejected +directions and the review findings (B1-B7) are recorded there. + +--- + +## The bug + +`repo_service.ts` handed the repo's **default branch** to `diffStat()` and `commitsAhead()` for +every worktree, regardless of what that worktree was actually destined for: + +```ts +const stat = await diffStat(e.path, defaultBranch); // every worktree, always +``` + +So a worktree stacked on another worktree's branch reported its **parent's work as its own** — a +child with 3 lines of its own on top of a 20-line parent showed `+23`. Meanwhile the base the user +picked at creation time was passed once to `git worktree add` and then discarded, and three +different code paths disagreed about what "the base" even was: + +| | Source of truth | Result | +| --- | --- | --- | +| the `+N −M` pill | repo default | wrong for any stacked worktree | +| worktree creation | user's pick, then discarded | lost immediately | +| wrap-up / fast-forward | the PR's `baseRefName ?? default` | right, and inconsistent with the above | + +## The reframe + +We do not model **base** (a fact about the past, which can be deleted and then poisons everything +downstream). We model **target**: *where this branch's work lands*. It is the same value for four +consumers, so there is one field: + +- the diff — `git diff ...HEAD`, i.e. **what a pull request into it would contain** +- the ahead count's fallback when a branch has no upstream +- `gh pr create --base` / `gh pr edit --base` +- the branch a wrap-up fast-forwards + +Three-dot diff means git finds the merge base live, so **no fork point is stored**. The diff also +self-heals: once a parent lands, `main` contains its commits and `main...HEAD` drops to the child's +own delta with no intervention. + +## The contract + +**Resolution** — `resolveTargetBranch()` in `repo_service.ts` is the single owner of precedence: + +1. primary checkout or detached → **null** (the primary *is* where branches land; a detached + worktree has no branch to land) +2. an **OPEN** pull request's `baseRefName` — the forge is authoritative while a PR is live, which + is also how we inherit GitHub's automatic PR retargeting without reimplementing it. A merged or + closed PR is history and stops overriding; an unrecognised state is treated as not + authoritative so a state this build predates cannot silently redirect work. +3. the **persisted** user choice +4. the **repo default** — deliberately last, and deliberately the fallback: it reproduces the + pre-feature behaviour exactly, so upgrading an existing install moves nobody's numbers until + they choose. + +A winner equal to the worktree's own branch is discarded and resolution continues (reachable via +`renameWorktreeBranch`, which keeps the path and therefore the stored target). + +**Rule 1 — one vocabulary.** `base` → `target` across server and app. Two documented exceptions: +`baseRefName` (GitHub's own field, mirrored from their API) and two **one-release** wire aliases — +`worktree.create` and `worktree.wrapUp` read `env.targetBranch || env.baseBranch`, the app sends +both keys, and `WrapUpReport.fromJson` reads both sets. The wrapUp alias is load-bearing, not +cosmetic: a client that predates the rename would otherwise send a key the server ignores, the +manager's `?? defaultBranchFor()` fallback would fast-forward the **wrong branch**, and the ack +would report success. Guarded by a test that fails the day someone deletes the alias without +shipping the app first. + +**Rule 2 — a branch rename follows through.** `renameWorktreeBranch` calls `renameTargetBranch`, +so every worktree landing in that branch moves with it. Without it a rename leaves them aiming at +a name that no longer resolves, for a rename that was none of their business. + +**Rule 3 — wrap-up hands its target down, recursively.** Wrapping up a branch repoints everything +that was landing in it to where *it* landed, via `resolveThroughChain` — so `leaf → mid → grand → +main` collapses correctly when the stack lands bottom-up. Guarded against cycles and +self-reference; refuses a default branch that is not itself live. + +**Rule 4 — a target that vanishes without a wrap-up falls back, and says so.** Someone ran +`git branch -D`, or the forge auto-deleted a merged head: there was no wrap-up, so nothing was +handed down, and from there "merged" and "abandoned" are indistinguishable. +`repairVanishedTargets` walks the chain first (so a stack that landed outside makit still +collapses to where it really went) then falls back to the repo default, and records +`retargetedFrom`. Cleared the moment the user picks a target explicitly — by then they own the +value and there is nothing left to tell them. + +**B7 — the lifecycle converges.** `adoptLivePrTargets` persists a live PR's base whenever it +disagrees with what is stored, so the two backing stores stop arguing at every edge: a PR opened +by hand against a different base is caught up, closing or reopening falls back to where the PR +actually pointed rather than a pre-PR value, and GitHub's auto-retarget-then-auto-close sequence +no longer drops us onto a stale target. Announced only when it *overrode* a value we already had — +agreement is not news. + +## Failure reporting + +`DiffStat.targetResolved` is false when the target cannot be resolved. This matters because the +failure mode is **not a zero**: when the `target...HEAD` leg fails, only that leg is skipped — +working-tree and untracked files still count — so an unresolvable target yields a *plausible small* +number that reads as "barely diverged". Clients suppress the pill (`Worktree.showsDiff`) rather +than publish a partial count, and the state is said out loud rather than merely hidden. + +## Where it lives in the UI + +The target is **create-time config**, so it spends no first-glance space: the worktree row and the +composer strip are unchanged. Three disclosures, one shared picker: + +| Home | Surface | Why | +| --- | --- | --- | +| **canonical** | worktree actions — `worktree_actions.dart` (mobile sheet) and `desktop_sidebar.dart` (hover `⋯`) | the target is a property of a **worktree**, and this is the only per-worktree menu — and the only entry that exists when a worktree has no session | +| convenience | the composer's `Ship it ⌄` menu, a "This worktree" group at the bottom | where your hand already is at ship time; prints its value inline | +| display | the detail sheet header — `branch ≫ target` | the only place head and target appear together, and with a PR the only place the branch appears at all (`status.identity` is `#`) | + +`≫` is `PhosphorIconsLight.caretDoubleRight` — *append into*, distinguishing a merge destination +from ordinary navigation. The line is asymmetric on purpose: the source is muted, the target +carries the emphasis and the affordance, because a pair at equal weight reads as two unrelated +facts when the point is that the right half is a control. + +The picker ranks candidates by *why* each is one — forked-from, repo default, other worktrees, all +branches — and previews the diff each would produce, capped at 4 through a bounded pool so opening +it cannot storm git. A local-only branch is listed **disabled with the reason**, because a PR base +must exist on the remote — *unless the repo has no remote at all*, in which case the rule is +vacuous and enforcing it would disable every row. + +## No stale state + +One `broadcastReposSnapshot()` refreshes every consumer, because there is no diff-level cache and +`reposProvider` is the app's single source of truth. `worktree.setTarget` validates the ref, +persists **atomically**, then broadcasts, then acks — in that order, because persisting after the +broadcast would compute the snapshot against the old target and ship stale numbers that look +correct until some unrelated event moved them. + +Two surfaces had to stop freezing their facts: `PrDetailBody` took a `PrStatus` as a constructor +parameter (stale-by-construction once its header hosted the picker) and `showWorktreeActions` +closed over its `Worktree`. Both now re-derive from `reposProvider`. `showPrDirectConfirm` still +freezes its values deliberately — a confirmation must describe what the user agreed to. + +Non-obvious consumers that follow from a target change: `hasChanges` gates the row's meta-line +visibility, and `insertions + deletions` drives worktree **sort order**, so retargeting can move a +row between the active and inactive partitions. + +## Decisions worth remembering + +- **Ahead/behind are upstream metrics, not target metrics.** `commitsAhead` prefers + `@{upstream}..HEAD` and only falls back to the target ref when a branch has no upstream; + `commitsBehind` takes no ref at all. Retargeting moves the diff and not these counts, which is + correct — they answer "what would a push send / a pull fetch". A target-relative commit count was + considered and dropped as YAGNI: the diff already conveys PR size. +- **`closestAncestorBranch`, not `merge-base --fork-point`.** Fork-point consults the reflog, which + is empty for a freshly created worktree and gone after a clone — it answers "unknown" exactly + when a suggestion is most wanted. Ties break by the caller's candidate order. +- **The announcement is added last in the signal list.** `PrStatus.loud` is `signals.first`, so + position *is* priority. Inserted earlier it became the composer strip's headline and pushed + `1 commit unpushed` into `+1 more`, letting an informational note crowd out something actionable. + +## Not done + +- Deleting the two one-release wire aliases (`env.baseBranch`, the `base*` report keys) once an app + carrying the new keys has shipped. +- A target-relative commit count, if "how many commits would this PR add" ever earns its place. diff --git a/mockups/base-branch.html b/mockups/base-branch.html new file mode 100644 index 00000000..693431c7 --- /dev/null +++ b/mockups/base-branch.html @@ -0,0 +1,1475 @@ + + + + + +makit — Target branch (v3): one field, forward-looking + + + + +

Target branch — one field, pointing forward

+

+ v3. Your reframe is the actual design. I had been modelling base — a fact about the + past, which can be deleted and then poisons everything downstream. You want target — a + choice about the future, which is always live because it is the thing you are aiming at. Same + gesture, but the whole edge-case surface collapses. +

+

+ Home 1 and Home 2 both stay. Home 2's line becomes + feat/base-branchmain + as you asked. I have one refinement to the notation and one to the interaction, in §3. + Your landing question is answered in §5 — GitHub has a documented rule for it, and we should just + adopt it. +

+ + +
+
+

As builtWhat actually shipped, and where this document is now wrong

+
The spec lives at + docs/specs/2026-08-11-SPEC-51-target-branch.md. This board keeps the rejected + directions and the review trail; the notes below stop it contradicting the code.
+
+
+
+
+
Superseded by the four rules
+
§5's “nowhere to land” is now rare, not the common case. + This board treats a vanished target as a state the user must fix. It is not: a wrap-up + hands its target down (rule 3, recursively), and a disappearance we did not perform + falls back to the repo default and announces it (rule 4). The blocking + PrSignal survives only for the genuinely unanswerable case — nothing + left to fall back to at all.
+
§5's “Pick a target” remedy was not built. Opening a + picker is navigation, and that remedy system is for server ops and canned prompts. The + announcement is a quiet fact instead, and the fix has three homes already.
+
§1's fork-point row was overstated (see B6). No fork point is + stored; one is still computed for the picker's top suggestion — via + closestAncestorBranch, not merge-base --fork-point, whose reflog + is empty for a fresh worktree.
+
B1 resolved as documented-upstream-metrics. Ahead/behind stay + upstream-relative and were dropped from R6 rather than re-plumbed. No new field.
+
B4 resolved by renaming, with a shim. The wire key is + targetBranch; the old key is read for one release and has a test that fails + if it is removed too early.
+
+
+
Found only by driving the real app
+
The picker was dead in a repo with no remote. Gating on + “exists on the remote” disabled every row in a plain + git init repo, because the rule was written for pull-request bases and there + was no remote for it to constrain. Now anyRemote ? onRemote.has(b) : true.
+
A merged PR kept overriding the user's choice. Resolution let any + PR's base win regardless of state, pinning a worktree to a destination already settled. + Only OPEN is authoritative now.
+
The announcement stole the headline. + PrStatus.loud is signals.first, so list position is + priority: inserted early, “was X, now Y” became the composer strip's headline + and pushed 1 commit unpushed into +1 more. Added last now, like + still a draft.
+
None of these were visible to unit tests. Two of the three were fixture + blind spots — the tests had no remote and only asserted the positive cases.
+
+
+ +
Shipped surfaces
+ + + + + + + + + + + + + + + + +
PieceWhereNote
Persistenceserver/src/worktree-target-store.tsPath-keyed, atomic temp+rename, value is {target, retargetedFrom?} with a + legacy bare-string read. GC'd on worktree removal, because worktree paths are + deterministic and a reused path would inherit a dead target.
Resolutionserver/src/repo_service.tsresolveTargetBranch (pure), plus adoptLivePrTargets and + repairVanishedTargets reconciling before the diff runs — so the + numbers and the label can never disagree inside one broadcast.
Candidatesserver/src/target_candidates.tsRanked groups + previews capped at 4 through a bounded pool. + resolveThroughChain does rule 3's recursion.
Commandsworktree.setTarget · worktree.targetCandidatesValidate → persist → broadcast → ack. Candidates is a read, so no + broadcast.
Pickerapp/lib/ui/widgets/lands_in_picker.dartLandsInLine + showLandsInPicker; sheet on touch, dialog on + desktop.
Three homesworktree_actions.dart · desktop_sidebar.dart · pr_detail.dartWorktree actions is canonical. “Lands in” is enabled where Rename is + blocked (open PR), which is the clearest signal that they are different kinds of + edit.
+
Gates at hand-off: server tsc --noEmit clean and + 1347 tests passing; app flutter analyze --fatal-infos clean, 319 tests across the + touched suites, flutter build macos --debug succeeds. The full + flutter test suite remains nondeterministic here (~40-60 “loading” + failures on a pristine baseline), so it certifies nothing either way — compare against a + stashed baseline before reading anything into it.
+
+
+ + +
+
+

ReviewFindings from three independent reviews — read before implementing

+
Design (adversarial), architecture (data model/protocol) and QA (testability) reviews, + each verified against the tree. Five blockers — the spec below is wrong in the places + flagged, and I have left the wrong claims visible with corrections rather than quietly editing + them.
+
+
+
Blockersmust be resolved before step 1
+ + + + + + + + + + + + + + + + + + + +
#FindingDetail & what it changes
B1The ahead/behind counts do NOT follow the target. §6b's “same ref, same meaning” + is false.commitsAhead (git.ts:407-418) tries + @{upstream}..HEAD first and only falls back to + baseBranch..HEADtwo-dot, not three — when the branch has no + upstream. commitsBehind (git.ts:425) takes no base argument + at all. So on any pushed worktree, retargeting moves the DiffChip and + nothing else: the N commits unpushed and N commits behind + signals keep telling an upstream-relative story on the same row. + Decision needed: add a genuinely target-relative commit count + (rev-list --count target...HEAD) as a new field, or drop ahead/behind + from R6 and say plainly that they are upstream metrics. Step 2's “fixes the pill + and the ahead count” over-promises.
B2There is nowhere to persist targetBranch. Step 1 says “persist it” + and no such store exists.projects.json holds {id, path} only + (project-store.ts:26-33); worktrees are enumerated live from + git worktree list on every snapshot (repo_service.ts:100, + id: e.path); SQLite carries sessions, events, media and ports — never + worktrees. Must be designed first. Path-keyed is the least-bad option (it survives + renameBranch, which keeps the path) but it resurrects a stale target + when a worktree is removed and recreated: git.ts:587 derives the path + deterministically from repo + name, so the new worktree inherits the dead one's setting. + Needs explicit GC on worktree.remove.
B3Step 3 wires the label but leaves the numbers stale.enrichPrs (repo_service.ts) only assigns w.pr — + it never recomputes the diff (verified: its only writes are w.pr = …). + diffStat/commitsAhead run once, earlier, in the git-only pass. + So if the PR's baseRefName differs from the persisted value, the pill's + numbers come from the local target while the label comes from the PR — + inside a single broadcast. Fix: resolve the target before + repoSnapshot's diffStat call through one pure + resolveTarget(worktree, pr, persisted); do not patch it in + enrichPrs.
B4The wire-key rename can silently fast-forward the wrong branch.worktree.wrapUp reads env.baseBranch + (ws/commands/worktree.ts:129manager.ts:797). Rename it and a + stale app still sends baseBranch → the server sees undefined → + ?? detectDefaultBranch() (manager.ts:803) → merges into the + wrong branch, with no error. This is the one irreversible failure in the rename. + Mitigation: accept env.targetBranch ?? env.baseBranch for one release, + and keep emitting both report keys.
B5R7 is undefined, and the real failure is worse than zeros.diffStat (git.ts:192-230) has no error channel. When the + target...HEAD leg fails, only that leg is skipped — working-tree and + untracked files still count. So an unresolvable target yields a plausible small + number, not zeros: strictly harder to notice than the bug R7 was written against. + “Suppressed” needs a wire representation (e.g. DiffStat.targetResolved:false, + or nullable insertions) before any test can tell it from “clean”.
+ +
+
+
Corrections to specific claims
+
    +
  • Type name. The TS interface is WrapUpResult + (manager.ts:57); the Dart mirror is WrapUpReport + (models.dart:1224). They already disagree across the boundary — §6b + cited only the Dart name against a TS line. Both get renamed, each under its own name.
  • +
  • Missed consumer: pr_signals.dart:788 passes + baseBehind: primary?.behindCount — the primary checkout's count, a + second and quite different behindCount consumer.
  • +
  • Sort is coarser than stated. repo_chips.dart:321-322 keys on + hasChanges before magnitude, so a retarget can flip a worktree + between the active and inactive partitions — not merely reorder within one.
  • +
  • Dead aggregates that would drift: models.dart:934-938 + totalInsertions / totalDeletions / + activeWorktreeCount have no callers today — and their docstring + already says “vs the default branch”, which is exactly the prose the rename must fix.
  • +
  • Say the quiet part: uncommittedFiles is correctly + target-independent (working tree only). It was omitted silently; it should be listed as + explicitly unaffected — because aheadCount is also effectively + target-independent, and that inconsistency is what exposed B1.
  • +
+
+
+
Reframed, and one finding rejected
+
Forge parity was backwards. There is no Forgejo/Gitea gateway — + zero hits in server/src, and manager.ts:199's + opts.gateway ?? createGithubGateway(...) is a test-fake seam, not a forge + substitution. So GitHub's automatic retargeting cannot be the primary mechanism §5 leans on: + build the splice (step 7) as the primary path and treat GitHub's auto-retarget as an + optimisation that happens to agree. That also removes the dependency on the unverified + auto-close hazard.
+
Picker previews need a bounded path. N candidates = N + git diff shell-outs fired the moment a picker opens. Route them through a + concurrency limit (cf. WORKTREE_CONCURRENCY, repo_service.ts), + not the snapshot fan-out, or opening the picker storms git.
+
One-field lossiness, accepted knowingly. You cannot show “my own + delta” independently of the merge destination. A stack targeting main with an + unlanded parent shows an inflated pill on every worktree until the parent lands. + §1 accepts this by fiat; if product later wants both numbers it is a schema migration, not a + tweak.
+
Rejected: “citation from the future”. The design review flagged + github/community#198570 (Jun 2026) as a fabricated future date. It is in the + past — this repo's own specs are dated 2026-08-* — and the reference came + verbatim from a web search, not from invention. Kept, still marked unverified pending the + spike.
+
Upheld by all three reviews: the core bug diagnosis + (repo_service.ts:114 + three-dot git.ts:216), the + startPoint and baseRefName carve-outs, both staleness bugs + (§6c Bug 1 and Bug 2), the generation-guard analysis, and the three-entry-point UX.
+
+
+ +
B6The fork point is not actually gone — §1 contradicts §4 and B2
+
§1 claims “Fork point: not needed — git diff target...HEAD is already three-dot.” + True for the diff, and only for the diff. Two other places still need a fork point + computed: (a) §4's picker leads with a Forked from group as its + top-ranked suggestion, and (b) B2's upgrade seeding must choose a value for every worktree + that already exists. So what v3 removes is the stored fork point, not the computation — + §1's row is overstated.
+
Which closes the open seeding question, against my earlier lean. I had + argued for seeding existing worktrees from their computed fork point (“the shift is the + fix”). The architecture review makes the better case: seed from the repo default, because + it exactly matches today's behaviour (repo_service.ts:114) — so upgrading moves no + number until the user chooses, and it needs no fork-point pass at all. Fork point stays a + suggestion inside the picker, where it is cheap and opt-in.
+ +
B7PR lifecycle transitions are unspecified
+
§5 says the PR's baseRefName is the source of truth when a PR exists + and the persisted value applies otherwise. The transitions are not specified, and each is + a visible wrong-value window: +
    +
  • PR created — the local value must yield to baseRefName, but only on the + next poll. They can legitimately differ if the user ran gh pr create --base by + hand; specify what is shown during that window.
  • +
  • PR closed or reopened — falls back to the persisted value, which may be stale + relative to where the PR actually pointed. Should closing adopt its baseRefName + into the persisted field?
  • +
  • The §5 auto-close hazard lands exactly here: retarget → auto-close → fall back to a + local value that no longer matches.
  • +
+ All three collapse if resolveTarget(worktree, pr, persisted) (B3) is the single owner + of precedence and writes its result back to the persisted field on every PR state change.
+ +
Revised test order (QA)
+
    +
  • First: the stacked end-to-end numbers test — it proves R1 behaviourally and would + have caught the original bug. Vehicle: the WS harness pattern in + server/test/.../repos_refresh_on_turn_end.test.ts.
  • +
  • Then: R5 both sheets (fails today by construction), then R8 rejection.
  • +
  • Demote: the R1-via-stub test (redundant with the end-to-end one) and the R4 generation + test (the guard already exists — regression-lock only).
  • +
  • Correct before writing: the per-consumer rebuild test must not assert + ahead/behind movement until B1 is decided, or it encodes a false contract.
  • +
  • Add: the ahead/behind divergence decision test; invalid-target suppression (blocked on + B5); concurrent setTarget + watcher race on the final frame; the + pr_signals.dart:788 primary-checkout consumer; R9 atomicity under two + simultaneous clients; and R10 rejection of a non-existent / unfetched ref.
  • +
  • Assert, don’t just accept: when the target is deleted mid-flight the git-only pass + throws and returns without emitting (server.ts:972-978), so the app keeps + the previous numbers — stale but not wrong-zero. That is the correct behaviour; pin it + with a test so a later refactor cannot turn it into an emit-of-zeros.
  • +
+
+
+ + +
+
+

The reframe“Base” was the wrong noun

+
Why swapping the noun deletes most of v2's complexity rather than renaming it.
+
+
+
Base (v1/v2)
Target (v3)
+
+
Tense
+
Past. Where this branch came from.
+
Future. Where this branch is going.
+
Can it vanish?
+
Yes — parents get merged and deleted constantly. Then the diff is uncomputable and git.ts:189 returns zeros.
+
Not meaningfully: if it dies we must re-point it, because you still have to land somewhere. A dangling target is a contradiction.
+
The diff means
+
“How far I have come.” Interesting, but not a thing you act on.
+
“What a PR into this branch would contain.” Exactly the number you are about to be judged on.
+
Fork point
+
Had to be computed and stored (merge-base --fork-point) because creation discarded it.
+
No stored fork point — git diff target...HEAD is already three-dot, so git finds the merge base live. See B6: one is still computed for the picker’s top suggestion.
+
Parent lands
+
Base disappears → broken state to design for.
+
Diff self-heals: main now contains the parent's commits, so main...HEAD silently drops to your own delta.
+
Fields
+
Two concepts fighting: local base for the diff, PR baseRefName for the merge — hence v2's awkward three-button “change locally only”.
+
One: targetBranch. It is the PR's base. Two buttons.
+
+
The collapse, concretely. v2 needed: a stored base, a stored fork point, + a separate PR base, a 3-button scope dialog, a warn-tone “base is gone” state, and a rule for + silent zeros. v3 needs: one nullable string, and the diff command it already runs + (git.ts:215) pointed at it.
+
And the pill becomes definitionally right. Today + repo_service.ts:114-116 diffs against the repo default. Under target semantics the + pill answers “what would my PR contain?” — so for a stacked worktree targeting its parent it is + +96 −12, and if you + deliberately target main instead it is + +1,204 −318 — which + is not a bug any more. That really is what such a PR would contain. A badly-aimed branch is + allowed to look big.
+
+
+ + +
+
+

Home 1The Ship it ⌄ menu — kept, relabelled

+
Unchanged from v2 except the noun. Strip above the fold is still untouched.
+
+
+
+
+
Strip at rest — zero pixels spent, exactly as shipped
+
+
+
+
+
makit
+
+
feat/base-branch
+
+1.1k−3064m
+
main
+
default
+
+
+
+
+
+ feat/base-branch· + 1 file uncommitted
+ Ship it + +
+
Why not use
+
Claude Opus 5 (US) + +
+
+
+
+
+
The sentence stays status.identity · status.loud.label + (pr_bar.dart:176-196). Nothing about the target competes with the fact that + wants you.
+
+
+
The caret, opened
+ +
“Lands in”, not “Target branch”. The menu is a list of verbs; a noun + entry reads as a heading. “Lands in main” is a fact you can also click, and it + uses the same word the picker's own title uses.
+
Still bottom-of-menu, below the divider, showing its value inline — so any + visit to the menu answers “where does this go?” for free.
+
+
+
+
+ + +
+
+

Home 2branch ≫ target — accepted, with two refinements

+
You asked for feat/base-branch >> target-branch with the target + selectable, and invited pushback. I am taking the idea; here is what I would change and why.
+
+
+
+
+
Todayas shipped
+
+
feat/base-branch
+
+
+
Needs you
+
+ 1 file uncommittedCommit & push
+
+
+
Nothing says where this lands.
+
+ +
+
v3no PR yet
+
+
feat/base-branch +
+ main + · + +1.1k−306
+
+
+
+
Needs you
+
+ 1 file uncommittedCommit & push
+
+
+
Refinement 1 — the chevron leads, the source does not repeat. The + title already is feat/base-branch, so printing it again one line down + wastes the line. Starting with ≫ main reads as a continuation of the title: + “feat/base-branch … lands in main”.
+
+ +
+
v3PR open — the source must appear
+
+
#143 +
feat/base-branch + + main
+
+
+
+
Needs you
+
+ 2 checks failingFix PR
+
+
+
This is why the line earns its place. With a PR, + status.identity is '#${pr.number}' + (pr_signals.dart:346) — the branch name is nowhere in the sheet today. + Your line is the only thing that shows head and target together, which is the one sentence + a PR is actually about.
+
+
+ +
+
+
On the >> notation — accepted, drawn as a glyph
+
    +
  • The concept is right and I would not change the direction. Left-to-right + “mine flows into theirs” matches how you described it. GitHub prints it the other way + (base ← compare) and it is worse: it leads with the branch you did not write.
  • +
  • Refinement 2 — render it as PhosphorIconsLight.caretDoubleRight + (confirmed present, phosphor_icons_light.dart:2387) rather than two literal + > characters. Everything else in this sheet is a Phosphor glyph; ASCII + here would be the only typographic exception, and at 12px the mono >> + sits low against the baseline of a proportional name.
  • +
  • Double, not single. A single is makit's generic “goes to”. The + doubled caret reads as append into — which is exactly the shell connotation you + were reaching for, and it distinguishes “lands in” from mere navigation.
  • +
  • One real risk, accepted. is also the media-player + “skip forward”. In a git context next to two branch names I do not think it can be + misread — but if it ever needs a fallback, into main in words is the + unambiguous version and fits the same space.
  • +
+
Asymmetry is deliberate. The source is muted mono; the target has a + faint fill, a hairline underline and a caret. A ≫ B with both halves at equal + weight reads as two static facts — the whole point is that the right half is a control.
+
+
+
Where else the same line goes, for free
+
    +
  • New-worktree dialog. new_worktree_dialog.dart already asks for a + base; relabel it “Lands in” and it is the same value, chosen at the same moment, with the + same widget. This is the primary path — §3/§4 are corrections.
  • +
  • Wrap-up brief. wrap_up.dart:313 already writes + “Land $identity on ${base ?? 'its base branch'}” — that fallback string exists precisely + because the value was missing. It becomes unconditional.
  • +
  • Worktree actions (§3b) — the canonical entry, and the only one that exists for a + worktree with no session.
  • +
  • Nowhere else. Not the row, not the strip, not the repo card. §2's argument stands + and v3 does not reopen it.
  • +
+
Where I would still push back on you slightly: making the target + selectable directly in the sheet header means a picker can open from a header, which no + other makit sheet does. It is worth it here — but it is the one novelty in this design, so + if it feels wrong in the hand, the fallback is a tap that closes the sheet and opens the + menu's picker instead. Same destination, one more hop.
+
+
+
+
+ + +
+
+

Home 3Worktree actions — and it should be the canonical one

+
You are right, and it is a better home than either of the other two: + targetBranch is a property of a worktree, and this is the only menu that is + keyed to a worktree. It also covers a state I had left with no entry point at all.
+
+
+
+
+
Todaymobile — long-press the row
+
+
feat/stack-b
+
+
+ Rename branch
+
+ Delete worktree
+
+
+
showWorktreeActions, + worktree_actions.dart:26. Header is already + worktree.branch ?? 'detached' — the exact left-hand side of the + line.
+
+ +
+
v3with the target
+
+
feat/stack-b +
+ feat/base-branch
+
+
+
+ Rename branch
+
+ Lands infeat/base-branch +
+
+ Delete worktree
+
+
+
Sits next to Rename, above Delete. Rename and Lands-in are siblings — + both edit a non-destructive branch property. Destructive stays last, which is the one + ordering rule this sheet already follows.
+
The header carries the value and the row carries it, deliberately: + the header is where you read it, the row is where you reach it with a thumb.
+
+ +
+
v3desktop — hover
+ +
_WorktreeMenuButton, + desktop_sidebar.dart:802. Same items, tooltip already reads + “Worktree actions”. No leading icons — that menu is bare + Text in PopupMenuItem, unlike the mobile ListTiles, + so the new row must not introduce one.
+
One wrinkle in the existing widget. Its docstring says the + button replaces the diff pill on hover — so reaching the target picker + hides the very number the target governs. Not a blocker, but it is why the menu item must + print its current value inline: you cannot glance back at the pill while the menu is open.
+
+
+ +
+
+
Why this is the canonical home, and Home 1 is the shortcut
+
The gap I missed. Home 1 lives in the composer. A worktree + with no session has no composer, so it has no Ship it ⌄ and no + Show detail — which is exactly the moment after you create a worktree, when the + target is most likely wrong and most likely worth fixing. Worktree actions is the only + per-worktree menu that exists in every state, on both platforms, from both the home list + and the sidebar.
+
    +
  • Scope matches. targetBranch is on WorktreeDTO, not on a + session. This menu takes a Worktree; the composer menu takes a + PrStatus. Putting a worktree setting in the worktree menu is the SOLID + answer.
  • +
  • Not three implementations. One picker (§4), three entries pointing at it. The + re-ranking is: worktree actions = canonical, Ship it ⌄ = convenience + while you are already shipping, sheet header line = display + that happens to be tappable.
  • +
  • If you wanted to cut one, cut the Ship it ⌄ entry — it is the most + redundant now. I would keep it anyway: it costs one MenuItemButton and it is + where your hand already is at ship time.
  • +
+
+
+
Disabled states — and one instructive asymmetry
+ + + + + + + + + + + + +
WorktreeRenameLands inReason shown
Primary checkoutblockedblocked“This is where branches land, not one that lands.” Mirrors + canRenameWorktree's primary guard.
Detached HEADblockedblocked“This worktree has no branch to land.”
Open PRblockedenabledRenaming would orphan the PR head — but retargeting is a first-class + operation (gh pr edit --base, §6).
Target deleted, unmergedenabledenabledThe one state that actively wants this item (§5).
+
The open-PR row is the useful one. It is the only entry in this menu + that is enabled precisely where Rename is blocked — which is the clearest possible + signal that changing where a branch lands is not the same kind of edit as changing its name. + Both guards already exist in code (worktree_actions.dart:11, + desktop_sidebar.dart:819); the new item just does not inherit the PR one.
+
All disabled rows keep the existing “visible but disabled, with the reason” + convention — subtitle on mobile, Tooltip on desktop.
+
+
+
+
+ + +
+
+

The picker — now “Lands in”, and it must say what is on the remote

+
One thing genuinely changes under target semantics: a PR base has to + exist on the remote. That is your “what if it does not exist yet”.
+
+
+
+
+
iOS — sheet, 44pt rows
+
+
+
+
Lands in +
feat/stack-b + + ?
+
+ +
Forked from
+
+ feat/base-branch + +96 −12 +
+
Repo default
+
main + +1.2k −318
+
Other worktrees
+
feat/ports-kill + +842 −1.9k
+
+ spike/local-onlynot pushed yet
+
+ feat/stack-bthis worktree
+
All branches · 37
+
release/1.4 + +3.4k −2.2k
+
+
+
+ +
+
Your question 1 — “what if the target does not exist yet?”
+
    +
  • A PR base must exist on the remote. So a local-only branch is listed but + disabled with the reason (not pushed yet) — the same + “explain the block, don't hide it” convention _whyNot already uses + (pr_detail.dart:742).
  • +
  • Not offered: typing a brand-new branch name. Creating the branch you intend to + merge into is a separate act with its own consequences; a picker that silently + git push -us a new remote branch would be doing something you did not ask + for.
  • +
  • Local-only is still a legal diff target. If we ever want that, the row + can enable for the pill and stay disabled for PR creation — but I would not build the + split until someone asks.
  • +
  • “Forked from”, not “fork point”. Under target semantics this group is just a + suggestion source — a good guess at where you probably want to land. It is no + longer a stored concept.
  • +
+
Previews stay one git diff --numstat target...HEAD each — + the same call git.ts:215 already makes, now with the honest ref. Only the + ranked few compute eagerly.
+
+
+
+
+ + +
+
+

Your question 2The parent landed and was deleted. Where does this go?

+
This has a documented answer, it is not ours to invent, and adopting it is + cheaper than any rule I would have written.
+
+
+
+ feat/stack-b + + feat/base-branch + + main + the middle link merges and is auto-deleted — the chain must splice, not break +
+ +
+
+
GitHub's rulePR open → already done
+
Pull Request Retargeting (GitHub changelog, 2020-05-19): when a + PR's head branch is merged and deleted, other open PRs whose base was that branch + “will be retargeted — the base branch of each pull request will be updated to + the merged pull request's base branch”. Previously they were closed.
+
    +
  • So the answer is not “fall back to the repo default”. It is + “wherever the parent itself landed” — which for a stack aimed at + release/1.4 is release/1.4, not main.
  • +
  • makit needs no logic here. The PR watcher already reads + baseRefName (github/queries.ts:143). GitHub rewrites it; we + pick it up on the next poll and the target updates itself.
  • +
  • Rule: when a PR exists, the PR's baseRefName is the source of + truth for targetBranch — makit mirrors it, never fights it.
  • +
+
One hazard, flagged not designed for. A community report + (github/community#198570, Jun 2026) has GitHub emitting + base_ref_deletedautomatic_base_change_succeeded → + closed one second apart — the retargeted PR was auto-closed and had to + be reopened, most likely because the new base already contained every commit. If makit + treats that closed as a normal ending it will announce “merged/closed” for a + PR the user still wants. Worth a probe against a real repo before step 6; I have not + verified it first-hand.
+
+ +
+
No PR → makit splices it
+
+
feat/stack-b +
+ main
+
+
+
Changed for you
+
+ feat/base-branch landed in + main and was deleted — now landing in + main
+
+
Needs you
+
+ 1 file uncommittedCommit & push
+
+
+
Splice, then say so once. Resolution order: (1) the deleted + branch's own merged PR base — ask the forge; (2) failing that, the first live ancestor in + the chain; (3) failing that, the repo default. It never asks, because there is no state in + which “dangling” is a legal answer.
+
And the numbers need no repair. main now contains the + parent's commits, so main...HEAD is your own delta — the pill goes from + +1.1k −306 to + +96 −12 + by itself. Under v2's base model this same event produced zeros.
+
+ +
+
The one real problem
+
+
feat/stack-b +
+ feat/base-branch
+
+
+
Needs you
+
+ target feat/base-branch + was deleted without landing — nowhere to merge + Pick a target
+
+
+
Deleted without being merged — abandoned, force-deleted, + renamed. Now there is genuinely no forward answer, so this is the only state that asks. It + is a PrSignal with a DirectRemedy, so it reaches + Needs you, the strip sentence and the CTA through the existing derivation — + and it is the one place the worktree row speaks up (option A from v2 §5, now with a much + narrower trigger).
+
Net effect of your reframe on the edge cases: v2 had one broken + state that was common (every landed parent). v3 has one broken state that is + rare (an abandoned target), and the common case fixes itself. That is the whole + argument for the noun change.
+
+
+
+
+ + +
+
+

Retargeting an open PR — two buttons now, not three

+
v2 needed “change locally only” because base and PR base were separate values. + With one field that option is incoherent, and the dialog gets simpler.
+
+
+
+
+
InstantNo PR yet
+ +
No confirm. One persisted field, two local git + re-reads, and gh pr create --base inherits it later. Undo is picking the old + value.
+
+
+
ConfirmPR #143 is open
+
+
+
+
+
Land #143 in main instead?
+
Retargets the pull request on GitHub from + feat/base-branch to main.
+
+
The PR would then contain +1,204 −318 instead of +96 −12 — + feat/base-branch's 41 commits come with it, because they are not in + main yet. GitHub may dismiss approvals and re-request review.
+
Runs gh pr edit 143 --base main. + Checks re-run against the new target.
+
Cancel + Retarget
+
+
+
+
Two buttons, and the warning is now the useful part. It is no longer + “these two fields will disagree” — it is a straight statement of what the PR would become, + which is the thing a reviewer will see.
+
Stacking is unaffected. Aim at the parent and your PR stays small; + that is the same act as before, just no longer described as a local override.
+
+
+
+
+ + +
+
+

Your call, appliedRename basetarget. Don't add a second field.

+
v3's step 1 said “add targetBranch”, which would have left two nouns + in the tree forever. Every existing site, classified — and exactly two that must + not become “target”.
+
+
+ + + + + + + + + + + + + + + + + + + + + +
SiteTodayBecomesWhy
git.ts:192 diffStat()baseBranchtargetBranchThe diff denominator is the target: target...HEAD = what a PR would contain.
git.ts:407 commitsAhead()baseBranchtargetBranch✗ See B1. Claimed “same ref, same meaning” — false. commitsAhead prefers @{upstream}..HEAD and only falls back to baseBranch..HEAD (two-dot) when there is no upstream. Rename the parameter for consistency, but it does not become target-relative.
manager.ts:797 wrapUpWorktree()baseBranchtargetBranchRuns git merge --ff-only origin/<ref> — literally the destination.
manager.ts:57 WrapUpResult (TS)
models.dart:1224 WrapUpReport (Dart)
baseBranch
baseUpdated
baseReason
targetBranch
targetUpdated
targetReason
“The branch that was caught up.” All three fields move together. The two sides already have different type names; rename each under its own. See B4 before touching the JSON keys.
manager.ts:521 createWorktree()baseBranchtargetBranchOne user-facing answer (“Lands in”) that now also gets persisted instead of + discarded at :559.
ws/commands/worktree.ts:16,129env.baseBranchenv.targetBranchWire key. App + server ship together so a coordinated rename is safe — but it is + a protocol break for an older client.
store.dart:899,1015 · new_session_sheet.dart:37 · new_worktree_dialog.dart:65 · repo_card.dart:372 · start_session.dart:99baseBranch
_baseBranch
targetBranch
_targetBranch
Creation-path UI state and the command it sends. Visible label becomes “Lands in”.
pr_signals.dart:303-390,787 PrResiduebaseBranch
baseBehind
targetBranch
targetBehind
main is N behind” — the branch wrap-up fast-forwards, i.e. the target.
+ +
+
+
Carve-out 1addWorktree takes a start point
+
git.ts:582,589 must not become targetBranch. + It is appended to git worktree add -b <branch> <path> <commit-ish> + — git's own noun is a start point, and it legally takes a tag or a SHA, which no + merge destination may be (§4). Calling it “target” would make the one place that genuinely + means “fork from here” lie.
+
And a collision is already waiting there. addWorktree holds + const target = join(base, repoName, opts.name) at git.ts:587 — + target is a filesystem path today, and baseDir/base + is the worktree root. Renaming the branch arg to targetBranch here would put + three unrelated “target”/“base” meanings inside twelve lines.
+
So: opts.baseBranchopts.startPoint. + createWorktree(targetBranch) passes it down as + startPoint: targetBranch — one answer from the user, two honest git-level roles: + fork from it now, land back in it later.
+
+
+
Carve-out 2baseRefName stays
+
protocol.ts:728 and models.dart:420-478 keep the + name. It mirrors GitHub's GraphQL field verbatim (github/queries.ts:143). + Renaming a wire mirror to match our own vocabulary is how you lose the ability to diff our + model against theirs.
+
Its role gets sharper, not weaker. Per §5, + baseRefName becomes the source of truth that populates + targetBranch whenever a PR exists — one arrow between the two vocabularies, in + one place. Everywhere else in the app, “base” stops existing.
+
Prose too, not just identifiers. repo_chips.dart:297 + (“Distinct base branches a new session can fork off”), + pr_signals.dart:85,95,332,476, wrap_up.dart:313,340 + (“its base branch”, “the base branch”). Two of those are user-visible strings — and step 9 + makes wrap_up.dart:313's ?? 'its base branch' fallback dead code.
+
+
+
Size: ~44 sites across server/src + app/lib, plus + server/test/ws/pr_commands.test.ts and 8 app test files. Mechanical, but it changes + WrapUpReport's JSON keys and one command's wire key — so server and app must land in + the same commit.
+
+
+ + +
+
+

NormativeNo stale state — what MUST update when the target changes

+
Written as numbered requirements so they lift verbatim into the SPEC. The good + news: the existing broadcast path already does the hard part, so most of this is + “do not break it”, plus two real bugs that only appear once the picker exists.
+
+
+ +
How a change actually reaches the screen today
+
+ worktree.setTarget + + persist + + broadcastReposSnapshot() + + listRepos() re-runs diffStat + commitsAhead + + repos.snapshot + + reposProvider +
+
There is no diff-level cache to invalidate. + diffStat() and commitsAhead() re-run from scratch on every + listRepos pass (repo_service.ts:114-116), and + reposProvider (store.dart:1228) is the app's single source of truth. + So one broadcast refreshes every consumer — the requirement is to guarantee the broadcast + happens, in the right order, and that nothing has captured a copy.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ReqRequirementWhere
R1worktree.setTarget MUST persist the new value before calling + broadcastReposSnapshot(). Broadcasting first computes the snapshot with the + old target and ships stale numbers that then look correct until the next + unrelated event.ws/commands/worktree.ts — join the existing pattern at :24, :54, :71, :87, :111, :142, :166
R2The command MUST send its ack after broadcastReposSnapshot() has been invoked — call-order, which is testable; “queued” was not.ws/commands/worktree.ts
R3MUST NOT introduce a debounce or throttle on this path. + throttledReposSnapshot (1000ms trailing, server.ts:721) exists + for turn-end coalescing; a user-initiated change must be immediate.server.ts:721,926
R4The two-phase emit MUST stay correct: broadcastReposSnapshot emits + git-only first, then PR-enriched (server.ts:979,997). Both frames carry the + diff numbers, so both must be computed post-change. The existing + reposSnapshotGeneration guard (server.ts:968,976,990) already + drops a superseded in-flight pass — this MUST NOT regress, and + lastEnrichedRepos / lastGitOnlyRepos + (server.ts:255,262, no TTL) MUST NOT be re-emitted after a target change.server.ts:964-1002
R5Open sheets MUST re-derive, not re-use. See the two bugs below — this is the only + place where “no stale state” needs new code rather than preservation.pr_detail.dart:38-56
worktree_actions.dart:26
R6Every consumer in the table below MUST reflect the new value without user action — no + pull-to-refresh, no reopen, no navigation.see consumer table
R7If the target is invalid at compute time the numbers MUST be suppressed. Blocked on B5: “suppressed” has no wire representation yet, and the real failure is not zeros but a plausible partial number — only the target...HEAD leg is skipped, so working-tree and untracked files still count.git.ts:189-227
repo_service.ts:114
R8A failed setTarget MUST NOT leave the UI showing the attempted value: + revert to the persisted one and surface the error. The picker is optimistic-free — it + renders from reposProvider, never from local selection state.store.dart · the picker widget
R9The persist step MUST be atomic. Concurrent setTarget calls are safe + only because listRepos re-reads persisted state, so last-persisted wins the + final frame — a torn write (read-modify-write on a JSON map, two clients at once) breaks + that guarantee silently. No proposed test covered this.the store chosen for B2
R10setTarget MUST validate the ref server-side — exists, and is fetched — + and reject otherwise. R8 specifies the UI of a rejection but never said what + causes one; without this the app can persist a target that no git command can resolve, which + is B5's partial-number failure arriving by the front door.ws/commands/worktree.ts · git.ts
+ +
+
+
Bug 1The detail sheet snapshots its own facts
+
showPrDetail(status:, pr:) takes PrStatus as a + constructor parameter (pr_detail.dart:38-56) and + PrDetailBody is a plain StatelessWidget + (pr_detail.dart:76). It is handed a value computed at open time and never looks + again.
+
Which is precisely the surface that now hosts the control. Home 2 puts + the ≫ target picker and the diff numbers in this sheet's + header. Change the target from inside it and the sheet keeps painting the old + +1.1k −306 until + you close and reopen it. Guaranteed stale, on the one screen where the user just acted.
+
Fix: PrDetailBody becomes a ConsumerWidget + that takes an identity (repoId + worktreePath) and re-derives + PrStatus from reposProvider each build. Callers + (repo_chips.dart:146, session_pr_chip.dart:60, + pr_bar.dart:120) pass the identity instead of the derived value.
+
+
+
Bug 2The worktree-actions sheet captures its Worktree
+
showWorktreeActions(..., worktree:) + (worktree_actions.dart:26) closes over the Worktree instance and + renders worktree.branch into SheetHeader. Under §3b that header + also carries ≫ target, and the sheet stays open across the change — + so the canonical home has the same defect as Bug 1.
+
Fix: same shape — resolve the Worktree from + reposProvider by path on each build. It is already a + WidgetRef-carrying function, so this is a lookup, not a signature change.
+
Deliberately NOT changed: + showPrDirectConfirm (wrap_up.dart:238) passes + uncommittedFiles and branch by value on purpose — a confirmation + must describe what the user agreed to, and its expectBranch guard re-checks + server-side. Freezing a confirmation is correct; freezing a display is not.
+
+
+ +
Consumer table — “everywhere it is used”
+ + + + + + + + + + + + + + + + + + + + + + + + +
FieldConsumerVisible effect of a target change
insertions
deletions
worktree_row.dart:230 (mobile)
desktop_sidebar.dart:645 (sidebar)
The two DiffChip render sites. The headline effect.
hasChangesworktree_row.dart:80Row expand/meta-line visibility — a worktree can go from “has a meta line” to + “doesn't”, changing row height.
insertions+deletionsrepo_chips.dart:321-324Sort order. Ranked by hasChanges then magnitude (:321-322, then :324), so a retarget can flip a worktree between the active and inactive partitions — not merely reorder within one. Rows MUST be keyed by worktree.path so they animate rather than swap identity.
aheadCountpr_signals.dart:433-436 → :776Does NOT move for a pushed branch (B1). Only never-pushed branches fall through to the base ref, and then two-dot.
behindCountpr_signals.dart:424-427 → :777Never moves (B1): commitsBehind takes no base argument — purely HEAD..@{upstream}. Missed second consumer: pr_signals.dart:788 uses the primary checkout’s count. Re-pointing this at the target is the open design question.
PrStatus (derived)repo_chips.dart:115 PrStatusChip
pr_bar.dart sentence + CTA
session_pr_chip.dart:60
wrap_up.dart:185
Tone dot, loud fact, +n more count, and which verb the CTA offers can + all change, because the signal list is rebuilt from the new counts.
uncommittedFilesrepo_chips.dart:161 · pr_signals.dart:775 · session_screen.dart:366 · desktop_chat_pane.dart:268 · worktree_starter.dart:356 · pr_bar.dart:137 · session_pr_chip.dart:75 · wrap_up.dart:185Explicitly UNAFFECTED — working-tree only, so a target change + must not move it. Listed on purpose: it was silently omitted before, and the fact that + aheadCount is also effectively target-independent yet was + listed is the inconsistency that exposed B1. Eight consumers, so a regression here is loud.
targetBranchworktree_actions.dart (both) · pr_detail.dart header · buildPrActionMenu inline valueThe three homes (§2, §3b, §3) each print the value; all three must show the new one.
+ +
Tests that prove it (TDD order)
+
    +
  • Server, R1 ordering: stub the broadcast, assert the persisted target is already the + new value when the broadcast fires — not merely that both happened.
  • +
  • Server, R4: fire setTarget while a snapshot is in flight; assert the + superseded pass emits nothing and the final frame carries the new numbers.
  • +
  • Server, end-to-end numbers: a real stacked fixture — worktree off a parent, assert + insertions drops from parent-inclusive to own-delta after + setTarget. This is the test that would have caught the original + repo_service.ts:114 bug.
  • +
  • App, R5 (both bugs): pump the sheet open, push a new repos.snapshot with + different numbers, assert the open sheet shows the new ones. Fails today by + construction.
  • +
  • App, R6: one widget test per consumer row above, asserting rebuild without any user + interaction.
  • +
  • App, R8: make setTarget reject; assert the picker shows the old value and + an error, not the attempted one.
  • +
+
+
+ + +
+
+

Build orderSmaller again than v2

+
Steps 1-2 are still the whole bug fix and touch no UI.
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#ChangeFileSizeWhy
1Rename basetarget (§6b) and persist it: targetBranch on WorktreeDTO + Dart Worktreeserver/src/protocol.ts:713
app/lib/store/models.dart:837
+ ~44 sites (§6b)
MOne noun, not two. Carve-outs: startPoint, baseRefName. Seed from the creation-time answer (already collected, currently discarded at + manager.ts:559), else repo default. No stored fork point — three-dot + diff finds it live.
2Point the git reads at itserver/src/repo_service.ts:114-116XSTwo arguments. Pill + ahead count become “what a PR into target would contain”. Ship + alone if you want the wrong number gone this week.
3PR is the source of truth when one exists: mirror baseRefName into targetBranchserver/src/repo_service.ts
server/src/github/queries.ts:143
XSBuys GitHub's auto-retargeting for free (§5). No splice logic needed for the PR case.
4worktree.setTarget + ranked candidates, incl. not pushed yetserver/src/ws/commands/worktree.ts
server/src/git.ts
MOne pure ranking function, two shells. Emits a repo snapshot so the pill re-renders + with no extra round trip.
5Menu entry “Lands in main ›” in a “This worktree” groupapp/lib/ui/widgets/pr_detail.dart:674SAppends a group to buildPrActionMenu. pr_bar.dart untouched.
5bWorktree actions entry — “Lands in x ›” in both action menus, + the header flow line on mobileapp/lib/ui/home/worktree_actions.dart:26
app/lib/desktop/chat/desktop_sidebar.dart:802
SThe canonical home (§3b): the only per-worktree menu, and the only entry that + exists with no session open. Reuses the existing disabled-with-reason guards but does + not inherit Rename's open-PR block.
6Sheet header flow line [src] ≫ target ⌄, target selectableapp/lib/ui/widgets/pr_detail.dart:226
app/lib/ui/widgets/sheet_header.dart
SSheetHeader gains an optional subtitle slot. Shows the head branch, which + a PR sheet does not show at all today.
7Splice on delete (no-PR case): merged-PR base → live ancestor → repo defaultserver/src/git.ts
server/src/manager.ts
SMirrors GitHub's own rule so both paths agree. Announced once as a + Changed for you fact, never a prompt.
8target deleted without landing as a PrSignal + DirectRemedyapp/lib/ui/widgets/pr_signals.dartSThe only genuinely broken state left. Reaches Needs you, the strip and the + row through the existing derivation.
8bNo-stale-state work (§6c): broadcast on setTarget, and make the detail + worktree-action sheets re-derive from reposProviderserver/src/ws/commands/worktree.ts
app/lib/ui/widgets/pr_detail.dart:38
app/lib/ui/home/worktree_actions.dart:26
SR1-R8. The broadcast is one line joining an existing pattern; the two sheets currently + snapshot their facts and would show stale numbers on the very screen the user just acted on.
9Target into PR + merge: gh pr create --base, gh pr edit --base, wrapUpWorktreeserver/src/manager.ts:745,803SKills the ?? detectDefaultBranch() fallback that can ff the wrong branch, + and makes wrap_up.dart:313's “its base branch” fallback string dead code.
10Relabel the creation dialog “Base branch” → “Lands in”app/lib/desktop/chat/new_worktree_dialog.dart:65
app/lib/ui/home/new_session_sheet.dart:37
XSSame value, same widget, honest noun — and it is the primary path, so it should use the + vocabulary the rest of the feature uses.
+
+
+
Deleted from v2 by the reframe
+
    +
  • The stored fork point and its merge-base --fork-point seeding.
  • +
  • The 3-button scope dialog — “change locally only” has nothing left to mean.
  • +
  • The base is gone warn state for landed parents — the common case + now self-heals silently.
  • +
  • Two competing notions of base in manager.ts and + repo_service.ts — now one noun, target (§6b).
  • +
+
+
+
Still open
+
    +
  • Verify the auto-close hazard (§5) against a real repo before step 3 — a spike + with two stacked PRs would settle it in ten minutes.
  • +
  • Existing worktrees on upgradeanswered in B6: seed from the repo + default (matches today’s behaviour, needs no fork-point pass, moves no number on upgrade).
  • +
  • Word choice: “Lands in” everywhere, or “Target” in the menu and “lands in” in + prose? I have used “Lands in” throughout on the theory that one word beats two.
  • +
  • behindCount semantics (§6c consumer table): keep it measured against + the upstream, or re-point it at the target so it predicts conflicts?
  • +
+
+
+
+
+ + + + + diff --git a/server/src/git.test.ts b/server/src/git.test.ts index 0b7614fd..72e75181 100644 --- a/server/src/git.test.ts +++ b/server/src/git.test.ts @@ -25,6 +25,10 @@ import { syncBaseBranch, deleteBranch, branchExists, + listLocalBranches, + listRemoteBranchNames, + hasOriginRemote, + closestAncestorBranch, } from "./git.js"; /** Init a throwaway repo with one commit on `main`. Returns its path. */ @@ -48,7 +52,7 @@ test("renameBranch renames the worktree's checked-out branch", async () => { repoPath: repo, name: "feature-x", branch: "old-name", - baseBranch: "main", + startPoint: "main", baseDir: base, }); await renameBranch(wtPath, "old-name", "new-name"); @@ -126,7 +130,7 @@ test("listWorktrees returns the primary tree, then added worktrees", async () => repoPath: repo, name: "feature-x", branch: "makit/feature-x", - baseBranch: "main", + startPoint: "main", baseDir: base, }); @@ -153,7 +157,7 @@ test("diffStat counts committed + uncommitted + untracked changes vs base", asyn repoPath: repo, name: "work", branch: "makit/work", - baseBranch: "main", + startPoint: "main", baseDir: base, }); const g = (...args: string[]) => execFileSync("git", args, { cwd: wtPath }); @@ -175,6 +179,79 @@ test("diffStat counts committed + uncommitted + untracked changes vs base", asyn } }); +/** + * B5: `diffStat` used to have no error channel, so an unresolvable target + * silently degraded to a working-tree-only count that looks like a small, + * legitimate diff — strictly harder to notice than a zero. These pin the + * `targetResolved` flag that lets callers suppress rather than mislead. + */ +test("diffStat reports targetResolved=true when the target resolves", async () => { + const repo = makeRepo(); + const base = mkdtempSync(join(tmpdir(), "makit-wt-")); + try { + const wtPath = await addWorktree({ + repoPath: repo, + name: "resolves", + branch: "makit/resolves", + startPoint: "main", + baseDir: base, + }); + const stat = await diffStat(wtPath, "main"); + assert.equal(stat.targetResolved, true); + } finally { + rmSync(repo, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } +}); + +test("diffStat flags an unresolvable target instead of returning a partial count", async () => { + const repo = makeRepo(); + const base = mkdtempSync(join(tmpdir(), "makit-wt-")); + try { + const wtPath = await addWorktree({ + repoPath: repo, + name: "gone", + branch: "makit/gone", + startPoint: "main", + baseDir: base, + }); + const g = (...args: string[]) => execFileSync("git", args, { cwd: wtPath }); + // A committed change, so a resolvable target would report insertions. + writeFileSync(join(wtPath, "README.md"), "hello\nline2\n"); + g("add", "."); + g("commit", "-q", "-m", "add line"); + // ...and uncommitted work, which is the part that used to leak through as a + // plausible small number when the committed leg failed. + writeFileSync(join(wtPath, "dirty.txt"), "wip\n"); + + const stat = await diffStat(wtPath, "no-such-branch"); + assert.equal(stat.targetResolved, false, "an absent target must be reported, not swallowed"); + // Defence in depth: a consumer that forgets the flag must degrade to + // "nothing", not a plausible small working-tree figure. The working-tree + // truth still lives in `uncommittedFiles`, so no information is lost. + assert.deepEqual( + { i: stat.insertions, d: stat.deletions, f: stat.filesChanged }, + { i: 0, d: 0, f: 0 }, + "an unresolvable target must zero the counts, not ship a partial reading", + ); + } finally { + rmSync(repo, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } +}); + +test("diffStat treats a null target as resolved (working-tree reading is intended)", async () => { + const repo = makeRepo(); + try { + // The primary checkout has no target; its numbers legitimately mean + // "uncommitted", so nothing is unresolved and callers must not suppress. + const stat = await diffStat(repo, null); + assert.equal(stat.targetResolved, true); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + test("slugify produces git-safe kebab-case and caps words", () => { assert.equal(slugify("Add a login form to the app!"), "add-a-login-form-to-the"); assert.equal(slugify(" Fix the:: BUG "), "fix-the-bug"); @@ -201,7 +278,13 @@ test("read helpers degrade gracefully on a non-repo path", async () => { assert.equal(await detectDefaultBranch(plain), null); assert.equal(await detectCurrentBranch(plain), null); assert.deepEqual(await listWorktrees(plain), []); - assert.deepEqual(await diffStat(plain, "main"), { insertions: 0, deletions: 0, filesChanged: 0 }); + assert.deepEqual(await diffStat(plain, "main"), { + insertions: 0, + deletions: 0, + filesChanged: 0, + // Not a repo at all: the target could not be resolved either. + targetResolved: false, + }); assert.equal(await uncommittedFileCount(plain), 0); } finally { rmSync(plain, { recursive: true, force: true }); @@ -562,6 +645,22 @@ test("syncBaseBranch refuses when the branch is checked out in two worktrees", a } }); +// ───────────────────────────────────────────────────────────────────────────── +// Target-candidate primitives (phase 2: the picker) +// ───────────────────────────────────────────────────────────────────────────── + +test("listLocalBranches returns every local branch, sorted", async () => { + const repo = makeRepo(); + try { + const g = (...args: string[]) => execFileSync("git", args, { cwd: repo }); + g("branch", "feat/b"); + g("branch", "feat/a"); + assert.deepEqual(await listLocalBranches(repo), ["feat/a", "feat/b", "main"]); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + // --------------------------------------------------------------------------- // SPEC-48 — the default-branch override, and why it must be checked rather than // trusted. @@ -624,6 +723,179 @@ test("an override rescues a repo whose origin/HEAD points at a branch that is go } }); +test("listLocalBranches is empty for a non-repo", async () => { + const plain = mkdtempSync(join(tmpdir(), "makit-plain-")); + try { + assert.deepEqual(await listLocalBranches(plain), []); + } finally { + rmSync(plain, { recursive: true, force: true }); + } +}); + +test("listRemoteBranchNames strips the remote prefix and skips HEAD", async () => { + const origin = makeRepo(); + const clone = mkdtempSync(join(tmpdir(), "makit-clone-")); + try { + execFileSync("git", ["clone", "-q", origin, clone]); + const g = (...args: string[]) => execFileSync("git", args, { cwd: clone }); + g("config", "user.email", "t@t.io"); + g("config", "user.name", "Test"); + g("checkout", "-q", "-b", "pushed-branch"); + g("push", "-q", "origin", "pushed-branch"); + g("checkout", "-q", "-b", "local-only"); + const remote = await listRemoteBranchNames(clone); + assert.equal(remote.has("pushed-branch"), true, "a pushed branch is on the remote"); + assert.equal(remote.has("local-only"), false, "an unpushed branch is not"); + // `origin/HEAD` is a symbolic alias, not a branch a PR can target. + assert.equal(remote.has("HEAD"), false); + // A branch that exists only on a NON-origin remote (e.g. `upstream`) is not a + // valid PR base — `gh` resolves against `origin` — so it must be excluded. + execFileSync("git", ["update-ref", "refs/remotes/upstream/upstream-only", "HEAD"], { + cwd: clone, + }); + const remote2 = await listRemoteBranchNames(clone); + assert.equal( + remote2.has("upstream-only"), + false, + "a branch on a non-origin remote is not a PR base", + ); + } finally { + rmSync(origin, { recursive: true, force: true }); + rmSync(clone, { recursive: true, force: true }); + } +}); + +test("hasOriginRemote is true only for an actual `origin`", async () => { + // Gates the whole "a PR base must exist on the remote" rule in the picker, and + // is origin-scoped to match `listRemoteBranchNames`. A repo whose only remote is + // `upstream` must read as NO origin: otherwise the gate switches on against an + // empty origin branch set and every candidate is disabled. + const origin = makeRepo(); + const clone = mkdtempSync(join(tmpdir(), "makit-clone-")); + try { + assert.equal(await hasOriginRemote(origin), false, "a plain `git init` has no remote"); + execFileSync("git", ["remote", "add", "upstream", "https://example.test/x/y.git"], { + cwd: origin, + }); + assert.equal( + await hasOriginRemote(origin), + false, + "an upstream-only repo has a remote, but not origin", + ); + execFileSync("git", ["clone", "-q", origin, clone]); + assert.equal(await hasOriginRemote(clone), true); + } finally { + rmSync(origin, { recursive: true, force: true }); + rmSync(clone, { recursive: true, force: true }); + } +}); + +test("closestAncestorBranch finds the branch a worktree forked from", async () => { + const repo = makeRepo(); + const base = mkdtempSync(join(tmpdir(), "makit-wt-")); + try { + // main -> feat/parent -> feat/child. Both main and feat/parent are ancestors + // of the child, so "closest" is what distinguishes the real fork parent. + const parent = await addWorktree({ + repoPath: repo, + name: "parent", + branch: "feat/parent", + startPoint: "main", + baseDir: base, + }); + writeFileSync(join(parent, "p.txt"), "p\n"); + execFileSync("git", ["add", "."], { cwd: parent }); + execFileSync("git", ["commit", "-q", "-m", "parent"], { cwd: parent }); + + const child = await addWorktree({ + repoPath: repo, + name: "child", + branch: "feat/child", + startPoint: "feat/parent", + baseDir: base, + }); + writeFileSync(join(child, "c.txt"), "c\n"); + execFileSync("git", ["add", "."], { cwd: child }); + execFileSync("git", ["commit", "-q", "-m", "child"], { cwd: child }); + + const found = await closestAncestorBranch(child, ["main", "feat/parent", "feat/child"]); + assert.equal(found, "feat/parent"); + } finally { + rmSync(repo, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } +}); + +test("closestAncestorBranch ignores the worktree's own branch and non-ancestors", async () => { + const repo = makeRepo(); + const base = mkdtempSync(join(tmpdir(), "makit-wt-")); + try { + const wt = await addWorktree({ + repoPath: repo, + name: "solo", + branch: "feat/solo", + startPoint: "main", + baseDir: base, + }); + writeFileSync(join(wt, "s.txt"), "s\n"); + execFileSync("git", ["add", "."], { cwd: wt }); + execFileSync("git", ["commit", "-q", "-m", "solo"], { cwd: wt }); + // A sibling with its OWN commit is genuinely not an ancestor of feat/solo. + // (Branching at `main` without committing would leave it *equal* to main and + // therefore a legitimate ancestor — which is why this needs a real commit.) + const sib = await addWorktree({ + repoPath: repo, + name: "sibling", + branch: "feat/sibling", + startPoint: "main", + baseDir: base, + }); + writeFileSync(join(sib, "sib.txt"), "sib\n"); + execFileSync("git", ["add", "."], { cwd: sib }); + execFileSync("git", ["commit", "-q", "-m", "sibling"], { cwd: sib }); + const found = await closestAncestorBranch(wt, ["feat/solo", "feat/sibling", "main"]); + assert.equal(found, "main"); + } finally { + rmSync(repo, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } +}); + +test("closestAncestorBranch returns null when nothing qualifies", async () => { + const plain = mkdtempSync(join(tmpdir(), "makit-plain-")); + try { + assert.equal(await closestAncestorBranch(plain, ["main"]), null); + } finally { + rmSync(plain, { recursive: true, force: true }); + } +}); + +test("closestAncestorBranch breaks a distance tie by candidate order", async () => { + const repo = makeRepo(); + const base = mkdtempSync(join(tmpdir(), "makit-wt-")); + try { + // `alias` points at the same commit as `main`, so both are ancestors at the + // same distance. The caller passes candidates in preference order, so the + // earlier one must win — deterministically, not by Object key order. + execFileSync("git", ["branch", "alias", "main"], { cwd: repo }); + const wt = await addWorktree({ + repoPath: repo, + name: "tie", + branch: "feat/tie", + startPoint: "main", + baseDir: base, + }); + writeFileSync(join(wt, "t.txt"), "t\n"); + execFileSync("git", ["add", "."], { cwd: wt }); + execFileSync("git", ["commit", "-q", "-m", "tie"], { cwd: wt }); + assert.equal(await closestAncestorBranch(wt, ["main", "alias"]), "main"); + assert.equal(await closestAncestorBranch(wt, ["alias", "main"]), "alias"); + } finally { + rmSync(repo, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } +}); + test("a default-branch override naming a remote-only branch is honoured", async () => { // Review finding: `branchExists` checks `refs/heads/` only, so an override naming // a branch that exists on the remote but is not checked out locally was treated as diff --git a/server/src/git.ts b/server/src/git.ts index c29a07d7..60b4a2dc 100644 --- a/server/src/git.ts +++ b/server/src/git.ts @@ -231,17 +231,39 @@ export interface DiffStat { insertions: number; deletions: number; filesChanged: number; + /** + * Whether the target ref could be resolved, so callers can tell "no committed + * delta" from "we could not measure one". + * + * Without this the two are indistinguishable: when the `target...HEAD` leg + * fails, only *that* leg is skipped — working-tree and untracked files still + * count — so an absent target yields a plausible SMALL number rather than a + * zero. That is strictly harder to notice than a zero, and it renders a + * stacked worktree as though it had barely diverged. A null target is + * `true`: there is nothing to resolve and the working-tree-only reading is + * the intended answer (see the note on `target` below). + */ + targetResolved: boolean; } -const ZERO_DIFF: DiffStat = { insertions: 0, deletions: 0, filesChanged: 0 }; +const ZERO_DIFF: DiffStat = { insertions: 0, deletions: 0, filesChanged: 0, targetResolved: true }; /** - * Total change size of a worktree relative to `baseBranch`: committed diff - * (`base...HEAD`) plus uncommitted working-tree changes (staged + unstaged + - * untracked). Best-effort — any git failure yields zeros. When `baseBranch` is - * null or equals the worktree's branch we count working-tree changes only. + * Total change size of a worktree relative to `targetBranch` — the branch this + * work is destined for: committed diff (`target...HEAD`, three-dot, so git + * finds the merge base live) plus uncommitted working-tree changes (staged + + * unstaged + untracked). In other words, **what a pull request into `target` + * would contain**. + * + * When `targetBranch` is null or equals the worktree's own branch we count + * working-tree changes only — there is no destination to compare against, so + * the number legitimately means "uncommitted". + * + * Best-effort on the counts, but NOT silent about the ref: a target that cannot + * be resolved sets `targetResolved: false` rather than quietly degrading to a + * working-tree-only figure that reads like a small real diff. */ -export async function diffStat(worktreePath: string, baseBranch: string | null): Promise { +export async function diffStat(worktreePath: string, targetBranch: string | null): Promise { const totals = { ...ZERO_DIFF }; const files = new Set(); @@ -261,10 +283,14 @@ export async function diffStat(worktreePath: string, baseBranch: string | null): // The three git reads are independent — run them concurrently. const cur = await detectCurrentBranch(worktreePath); + // Only a target that is BOTH set and different from our own branch implies a + // committed-delta measurement that could fail. Equal/null means "nothing to + // resolve", which is a success, not a silent skip. + const measuresTarget = Boolean(targetBranch) && cur !== targetBranch; const [committed, working, untracked] = await Promise.all([ - // Committed delta vs the merge-base with the default branch. - baseBranch && cur !== baseBranch - ? git(["diff", "--numstat", `${baseBranch}...HEAD`], worktreePath) + // Committed delta vs the merge base with the target branch (three-dot). + measuresTarget + ? git(["diff", "--numstat", `${targetBranch}...HEAD`], worktreePath) : Promise.resolve(null), // Uncommitted: staged + unstaged tracked changes. git(["diff", "--numstat", "HEAD"], worktreePath), @@ -273,15 +299,145 @@ export async function diffStat(worktreePath: string, baseBranch: string | null): ]); if (committed && committed.code === 0) addNumstat(committed.stdout); + // A requested-but-failed committed leg is the whole point of the flag: report + // it so the caller suppresses the pill instead of publishing a partial count. + if (measuresTarget && committed?.code !== 0) totals.targetResolved = false; + // A path that is not a git repo at all resolves nothing, even with no target: + // `git diff HEAD` failing is the only signal we get that the read was void. + if (!measuresTarget && working.code !== 0) totals.targetResolved = false; if (working.code === 0) addNumstat(working.stdout); if (untracked.code === 0) { for (const line of untracked.stdout.split("\n")) if (line.trim()) files.add(line.trim()); } totals.filesChanged = files.size; + // A missed `targetResolved` check at any consumer must degrade to "nothing", + // not a plausible partial reading: zero the magnitudes when the target could + // not be resolved. The working-tree truth is still carried separately by + // `uncommittedFiles`, so no real information is lost. + if (!totals.targetResolved) return { ...ZERO_DIFF, targetResolved: false }; return totals; } +/** + * Every local branch, sorted. `for-each-ref` (not `git branch`) so the output is + * plain refnames with no decoration, no `*` marker and no colour. + */ +export async function listLocalBranches(repoPath: string): Promise { + const r = await git(["for-each-ref", "--format=%(refname:short)", "refs/heads"], repoPath); + if (r.code !== 0) return []; + return r.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .sort(); +} + +/** + * Branch names that exist on the `origin` remote, with the remote prefix + * stripped (`origin/feat/x` -> `feat/x`). + * + * Used to mark a candidate as unusable for a pull request: a PR base must exist + * on the remote, so a local-only branch is offered but disabled rather than + * silently accepted and rejected later by `gh`. + * + * Scoped to `refs/remotes/origin` on purpose: `gh` resolves a PR base against + * `origin`, so a branch that exists only on another remote (`upstream`, a fork) + * is NOT a valid base and must not be offered as one. + * + * `origin/HEAD` is skipped — it is a symbolic alias for the default branch, not a + * branch of its own, and offering it would list the default twice. + */ +export async function listRemoteBranchNames(repoPath: string): Promise> { + const r = await git(["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"], repoPath); + const out = new Set(); + if (r.code !== 0) return out; + for (const line of r.stdout.split("\n")) { + const ref = line.trim(); + if (!ref) continue; + const slash = ref.indexOf("/"); + if (slash < 0) continue; + const name = ref.slice(slash + 1); + if (!name || name === "HEAD") continue; + out.add(name); + } + return out; +} + +/** + * True when the repo has an **`origin`** remote. + * + * Deliberately origin-specific, not "any remote": it gates the "a PR base must + * exist on the remote" rule, and {@link listRemoteBranchNames} only reads + * `refs/remotes/origin`. A repo whose only remote is `upstream` would otherwise + * turn the gate ON while the branch set came back EMPTY, disabling every + * candidate and making the picker unusable. + */ +export async function hasOriginRemote(repoPath: string): Promise { + const r = await git(["remote"], repoPath); + if (r.code !== 0) return false; + return r.stdout + .split("\n") + .map((l) => l.trim()) + .includes("origin"); +} + +/** + * The branch a worktree most likely forked from: the *closest* ancestor of HEAD + * among `candidates`. + * + * "Ancestor" alone is not enough — after `feat/parent` forks off `main`, both are + * ancestors of `feat/child`, and only the nearer one is the real parent. So among + * the ancestors we take the one with the fewest commits between it and HEAD. + * + * Deliberately NOT `git merge-base --fork-point`: that consults the reflog, which + * is empty for a freshly created worktree and absent entirely after a clone or a + * prune, so it answers "unknown" exactly when we most need a suggestion. This is + * a *suggestion source* for the picker, not a stored value. + * + * Ties are broken by **candidate order**, so callers should pass candidates in + * preference order: two branches can sit on the same commit (a freshly cut alias), + * in which case they are equidistant and the caller's ranking is the only + * meaningful discriminator. + * + * Returns null when nothing qualifies (not a repo, unborn HEAD, or every + * candidate is unrelated). + */ +export async function closestAncestorBranch( + worktreePath: string, + candidates: readonly string[], +): Promise { + const own = await detectCurrentBranch(worktreePath); + // One git read per candidate, run in parallel (bounded) instead of two serial + // reads each: on a repo with many branches the old fan-out was up to 2N serial + // subprocesses on every picker open. `rev-list --left-right --count B...HEAD` + // yields "\t": B is an ancestor of HEAD iff nothing is reachable + // from B but not HEAD (behind === 0), and the distance is then the commits + // between them (ahead). `mapLimit` preserves input order, so the candidate-order + // tie-break below is unchanged. + const measured = await mapLimit(candidates, WORKTREE_READ_CONCURRENCY, async (branch) => { + // Its own branch is an ancestor of itself; targeting yourself is meaningless. + if (!branch || branch === own) return null; + const r = await git(["rev-list", "--left-right", "--count", `${branch}...HEAD`], worktreePath); + if (r.code !== 0) return null; + const [behindRaw, aheadRaw] = r.stdout.trim().split(/\s+/); + const behind = Number.parseInt(behindRaw, 10); + const ahead = Number.parseInt(aheadRaw, 10); + // behind > 0 means B carries commits HEAD lacks: not an ancestor. + if (!Number.isFinite(behind) || behind !== 0) return null; + if (!Number.isFinite(ahead)) return null; + return { branch, distance: ahead }; + }); + let best: { branch: string; distance: number } | null = null; + for (const m of measured) { + if (m === null) continue; + // Strictly `<`, so an equidistant later candidate never displaces an earlier + // one — that is what makes the caller's ordering the tie-breaker. + if (best === null || m.distance < best.distance) best = m; + } + return best?.branch ?? null; +} + /** True when a local branch `refs/heads/` exists. */ export async function branchExists(repoPath: string, branch: string): Promise { const r = await git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], repoPath); @@ -453,10 +609,10 @@ export async function uncommittedFileCount(worktreePath: string): Promise { +export async function commitsAhead(worktreePath: string, targetBranch: string | null): Promise { const parse = (s: string): number => { const n = Number.parseInt(s.trim(), 10); return Number.isFinite(n) ? n : 0; @@ -464,8 +620,8 @@ export async function commitsAhead(worktreePath: string, baseBranch: string | nu const up = await git(["rev-list", "--count", "@{upstream}..HEAD"], worktreePath); if (up.code === 0) return parse(up.stdout); // No upstream configured: count commits ahead of the base branch instead. - if (!baseBranch) return 0; - const base = await git(["rev-list", "--count", `${baseBranch}..HEAD`], worktreePath); + if (!targetBranch) return 0; + const base = await git(["rev-list", "--count", `${targetBranch}..HEAD`], worktreePath); return base.code === 0 ? parse(base.stdout) : 0; } @@ -706,7 +862,7 @@ export function slugifyBranch(text: string, maxLength = 80): string { /** * Create a new worktree at `//` on a fresh - * branch `branch`, based off `baseBranch`. Returns the absolute worktree path, + * branch `branch`, forked from `startPoint`. Returns the absolute worktree path, * canonicalized (symlinks resolved) so it matches what `git worktree list` * reports — callers store this as `session.worktreePath` and later compare it * against git's output to link sessions to worktrees, which silently breaks if @@ -718,14 +874,22 @@ export async function addWorktree(opts: { repoPath: string; name: string; branch: string; - baseBranch?: string | null; + /** + * Where the new branch forks FROM — git's own noun for this argument is a + * commit-ish, and it legally accepts a tag or a SHA. Deliberately NOT called + * `targetBranch`: a merge destination must be a branch, and this function + * already uses `target` for the filesystem path it creates and `baseDir` for + * the worktree root. Callers pass the user's chosen target here because at + * creation time you fork from the branch you intend to land back in. + */ + startPoint?: string | null; baseDir?: string; }): Promise { const base = opts.baseDir ?? worktreeBaseDir(); const repoName = basename(resolve(opts.repoPath)); const target = join(base, repoName, opts.name); const args = ["worktree", "add", "-b", opts.branch, target]; - if (opts.baseBranch) args.push(opts.baseBranch); + if (opts.startPoint) args.push(opts.startPoint); // No timeout: populating a worktree on a big repo can take a while. const r = await run("git", args, opts.repoPath); if (r.code !== 0) { diff --git a/server/src/manager.test.ts b/server/src/manager.test.ts index ec637923..8c838f89 100644 --- a/server/src/manager.test.ts +++ b/server/src/manager.test.ts @@ -1777,9 +1777,9 @@ test("wrapUpWorktree reports the base branch it could not catch up", async () => await withWorktreeEnv(async ({ manager, projectId }) => { const wt = await manager.createWorktree(projectId); const result = await manager.wrapUpWorktree(projectId, wt.path, "main"); - assert.equal(result.baseBranch, "main"); - assert.equal(result.baseUpdated, false); - assert.ok(result.baseReason, "it must explain why the base was not updated"); + assert.equal(result.targetBranch, "main"); + assert.equal(result.targetUpdated, false); + assert.ok(result.targetReason, "it must explain why the base was not updated"); }); }); @@ -1811,7 +1811,7 @@ test("wrapUpWorktree falls back to the repo's default branch", async () => { await withWorktreeEnv(async ({ manager, projectId }) => { const wt = await manager.createWorktree(projectId); const result = await manager.wrapUpWorktree(projectId, wt.path); - assert.equal(result.baseBranch, "main"); + assert.equal(result.targetBranch, "main"); }); }); diff --git a/server/src/manager.ts b/server/src/manager.ts index 0fb82e47..3bedc4e5 100644 --- a/server/src/manager.ts +++ b/server/src/manager.ts @@ -36,6 +36,18 @@ import { listAcpSessions } from "./adapters/acp.js"; import { listCodexThreads } from "./adapters/codex.js"; import type { AgentSessionInfo } from "./adapters/adapter.js"; import { listRepos, enrichPrs, type LastKnownPr } from "./repo_service.js"; +import { + putTarget, + clearTarget, + loadTargets, + renameTargetBranch, + worktreeTargetsFile, +} from "./worktree-target-store.js"; +import { + targetCandidates as computeTargetCandidates, + resolveThroughChain, + type TargetCandidate, +} from "./target_candidates.js"; import type { GithubGateway } from "./github/gateway.js"; import { createDefaultForgeGateway } from "./forge/router.js"; import type { PersistedProject } from "./project-store.js"; @@ -47,6 +59,7 @@ import { addWorktreeForPr, removeWorktree, renameBranch, + listLocalBranches, deleteBranch, syncBaseBranch, listOpenPrs, @@ -82,16 +95,16 @@ export interface WrapUpResult { /** The local branch that was deleted, or undefined for a detached worktree. */ branchDeleted?: string; /** - * Why the branch survived, when it should have gone. Like {@link baseReason}, + * Why the branch survived, when it should have gone. Like {@link targetReason}, * this is reported rather than thrown: the worktree is already removed by then, * and the caller cannot retry because the path is no longer a worktree. */ branchReason?: string; /** The branch that was caught up, or undefined when none could be resolved. */ - baseBranch?: string; - baseUpdated: boolean; - /** Why the base branch was not updated, when that is worth surfacing. */ - baseReason?: string; + targetBranch?: string; + targetUpdated: boolean; + /** Why the target branch was not updated, when that is worth surfacing. */ + targetReason?: string; } export interface AdapterFactoryContext { @@ -794,29 +807,37 @@ export class SessionManager extends EventEmitter { } /** - * Create a fresh worktree off `baseBranch` (default branch when unset) with - * an auto-generated branch name (or a slugified `branchName` when supplied), - * WITHOUT a session (the + New worktree flow). + * Create a fresh worktree that will land in `targetBranch` (the repo default + * when unset) with an auto-generated branch name (or a slugified `branchName` + * when supplied), WITHOUT a session (the + New worktree flow). + * + * The one answer serves two git-level roles: the new branch **forks from** + * this ref now (it is passed to `addWorktree` as its `startPoint`), and the + * work **lands back in** it later, so it is also persisted as the worktree's + * target. Persisting is the point: before this, the base the user picked here + * was used once for `git worktree add` and then discarded, which is why the + * diff pill measured every worktree against the repo default. + * * The worktree exists immediately; the session is started later once the * user picks a harness and sends the first message (see spawnPendingSession * with a bound worktreePath). For a non-git project, returns the repo dir. */ async createWorktree( projectId: string, - baseBranch?: string, + targetBranch?: string, branchName?: string, ): Promise<{ path: string; branch: string | null }> { const project = this.projects.get(projectId); if (!project) throw new Error(`unknown project: ${projectId}`); const repoPath = project.dto.path; if (!(await isGitRepo(repoPath))) return { path: repoPath, branch: null }; - const base = - baseBranch && (await branchExists(repoPath, baseBranch)) - ? baseBranch + const target = + targetBranch && (await branchExists(repoPath, targetBranch)) + ? targetBranch : await this.defaultBranchFor(repoPath); // Unborn HEAD (no commits yet): `git worktree add -b` would fail, so run // the session in the repo dir instead of forking a worktree. - if (!base) return { path: repoPath, branch: null }; + if (!target) return { path: repoPath, branch: null }; // A user-supplied name is slugified to a git-safe ref; blank/invalid names // fall back to the auto-generated `worktree-`. `slugifyBranch` keeps // `/` so hierarchical names like `feat/new-ui` survive as-is; either way @@ -842,8 +863,19 @@ export class SessionManager extends EventEmitter { repoPath, name: dirName, branch, - baseBranch: base, + startPoint: target, }); + // Persist the target now that we know the worktree's path. Without this + // the answer is lost the moment `git worktree add` returns, and every + // consumer falls back to the repo default -- the original bug. + if (!putTarget(worktreeTargetsFile(), path, target)) { + // The worktree exists; we cannot un-create it, so this is best-effort. + // Log so a failed persist (full/read-only store) is observable instead of + // silently degrading the diff to the repo default. + log.warn( + `[makit] created worktree ${path} but could not persist its target ${target} (store not writable)`, + ); + } return { path, branch }; }); } @@ -932,6 +964,22 @@ export class SessionManager extends EventEmitter { throw new Error(`cannot rename ${oldName}: it has an open pull request`); } await renameBranch(worktreePath, oldName, newName); + // Rule 2: every worktree that LANDS IN this branch must follow the rename. + // Targets are stored by name, so without this the rename leaves each of them + // aiming at a name that no longer resolves -- their diff becomes unmeasurable + // and they look broken, for a rename that was none of their business. Scoped + // to THIS repo's worktree paths: the store is global and branch names are not + // unique across repos, so an unscoped rewrite would drag along a same-named + // target in an unrelated repo. + const scope = new Set(trees.map((t) => resolve(t.path))); + if (renameTargetBranch(worktreeTargetsFile(), oldName, newName, scope) === null) { + // git already renamed the branch, so this cannot fail the operation. Log it: + // every worktree that landed in `oldName` still has that name on disk, and + // will read as `targetResolved: false` until the store is writable again. + log.warn( + `[makit] renamed ${oldName} -> ${newName} but could not persist the worktree targets aiming at it (store not writable)`, + ); + } } /** @@ -958,7 +1006,7 @@ export class SessionManager extends EventEmitter { worktreePath, expectBranch, ); - return { branchDeleted, branchReason, baseUpdated: false }; + return { branchDeleted, branchReason, targetUpdated: false }; } /** @@ -970,6 +1018,73 @@ export class SessionManager extends EventEmitter { * "no branch to delete" and lets {@link removeWorktree} produce the error — so * returning the entry and letting each caller rule on it keeps both readable. */ + /** + * Set the branch `worktreePath`'s work lands in. + * + * Validates before it writes (R10): the worktree must belong to the project, + * must not be the primary checkout (that IS where branches land), must be on a + * branch, must not target itself, and the ref must actually exist. Without the + * existence check the app could persist a target no git command can resolve, + * which surfaces later as a `targetResolved: false` worktree whose committed + * delta is simply unmeasurable -- a self-inflicted version of the "target was + * deleted" state. + * + * The write is atomic (see `worktree-target-store`), which is what makes + * concurrent calls safe: `listRepos` re-reads the store on every snapshot, so + * last-persisted wins the final frame. A torn read-modify-write would break + * that guarantee silently, and the value decides where code gets merged. + * + * Returns the stored target so the ack can echo it back rather than the caller + * assuming its own request succeeded verbatim. + */ + async setWorktreeTarget( + projectId: string, + worktreePath: string, + targetBranch: string, + ): Promise<{ worktreePath: string; targetBranch: string }> { + const { repoPath, wt } = await this._locateWorktree(projectId, worktreePath); + if (!wt) throw new Error(`worktree is not part of project ${projectId}: ${worktreePath}`); + if (wt.isPrimary) { + throw new Error("the primary checkout is where branches land, not one that lands"); + } + if (!wt.branch) throw new Error("a detached worktree has no branch to land"); + if (wt.branch === targetBranch) { + throw new Error(`a worktree cannot land in its own branch (${targetBranch})`); + } + if (!(await branchExists(repoPath, targetBranch))) { + throw new Error(`no such branch: ${targetBranch}`); + } + // Store under the canonical path so the key matches `WorktreeDTO.id`, which + // `listWorktrees` reports symlink-resolved. + const key = resolve(wt.path); + // An interactive change must not ack a success the next snapshot contradicts: + // if the write is refused (full/read-only disk, permissions), tell the user. + if (!putTarget(worktreeTargetsFile(), key, targetBranch)) { + throw new Error("could not save where this worktree lands (the target store is not writable)"); + } + return { worktreePath: key, targetBranch }; + } + + /** + * Ranked target-branch candidates for the picker (grouped by why each is a + * candidate, with a diff preview on the leading few). + * + * Per-request, NOT part of the snapshot: previews are real `git diff` calls, so + * folding them into `broadcastReposSnapshot` would multiply them by every + * worktree on every broadcast. + */ + async targetCandidates(projectId: string, worktreePath: string): Promise { + const { repoPath, wt } = await this._locateWorktree(projectId, worktreePath); + if (!wt) throw new Error(`worktree is not part of project ${projectId}: ${worktreePath}`); + // Thread the stored default-branch override so the picker's `default` group + // names the same branch every diff and new worktree uses. + return computeTargetCandidates( + repoPath, + resolve(wt.path), + this.settingsForPath(repoPath).defaultBranch, + ); + } + private async _locateWorktree( projectId: string, worktreePath: string, @@ -1099,41 +1214,114 @@ export class SessionManager extends EventEmitter { * * **Only step 1 is fatal.** If the worktree survives, nothing was tidied and the * caller must say so. Steps 2 and 3 are best-effort and *reported* - * (`branchReason`, `baseReason`): by then the worktree is gone and the client + * (`branchReason`, `targetReason`): by then the worktree is gone and the client * cannot retry — the path is no longer a registered worktree — so throwing * would describe a mostly-done job as a total failure. * - * [baseBranch] should be the PR's own `baseRefName`; it falls back to the - * repo's default branch for an older server or a shed PR lookup. + * [targetBranch] should be the PR's own `baseRefName` (GitHub's word for the + * same thing); it falls back to the repo's default branch for an older client + * or a shed PR lookup. */ async wrapUpWorktree( projectId: string, worktreePath: string, - baseBranch?: string, + targetBranch?: string, expectBranch?: string, ): Promise { const { repoPath, branchDeleted, branchReason } = await this._removeWorktreeAndBranch(projectId, worktreePath, expectBranch); - const base = baseBranch ?? (await this.defaultBranchFor(repoPath)); + const base = targetBranch ?? (await this.defaultBranchFor(repoPath)); if (!base) { return { branchDeleted, branchReason, - baseUpdated: false, - baseReason: "the repo has no default branch to catch up", + targetUpdated: false, + targetReason: "the repo has no default branch to catch up", }; } const sync = await syncBaseBranch(repoPath, base); + // Rule 3: hand this target down. Every worktree that was landing in the branch + // we just tidied away now lands where IT landed -- which is the only answer + // that keeps a stack working. Recursive, because the branch we hand them may + // itself already be gone (a stack landing bottom-up in one sitting). + if (branchDeleted) await this._handDownTarget(repoPath, branchDeleted, base); return { branchDeleted, branchReason, - baseBranch: base, - baseUpdated: sync.updated, - baseReason: sync.reason, + targetBranch: base, + targetUpdated: sync.updated, + targetReason: sync.reason, }; } + /** + * Rule 3's fan-out: repoint every worktree that was landing in `goneBranch` to + * `landedIn`, following the chain when that is itself already gone. + * + * The note is deliberately recorded so the change is announced rather than + * silent -- a worktree's diff and its future pull request both change + * destination here, and doing that invisibly is how someone opens a PR against + * the wrong branch without noticing. + */ + private async _handDownTarget( + repoPath: string, + goneBranch: string, + landedIn: string, + ): Promise { + const file = worktreeTargetsFile(); + const all = loadTargets(file); + // Cheap pre-check on the GLOBAL store: skip the git work when nothing anywhere + // lands in the gone branch. + if (!Object.values(all).some((e) => e.target === goneBranch)) return; + const [locals, trees] = await Promise.all([ + listLocalBranches(repoPath), + listWorktrees(repoPath), + ]); + // `all` spans every project the server knows; only THIS repo's worktrees may + // be handed down. Branch names are not unique across repos, so without this a + // wrap-up of `develop` here would silently retarget a `develop`-bound worktree + // in an unrelated repo. `trees` is exactly this repo's worktree set. + const here = new Set(trees.map((t) => resolve(t.path))); + const affected = Object.entries(all).filter( + ([path, e]) => e.target === goneBranch && here.has(path), + ); + if (affected.length === 0) return; + // `landedIn` is the branch this wrap-up actually landed on (the PR's base or + // the repo default), so it is authoritative even when the fetch did not land + // it locally (offline). Seed it into the live set directly rather than + // trusting `refs/remotes/origin/*`: a stale remote-tracking ref left behind + // after a merged branch is auto-deleted is NOT proof the branch still exists. + const live = new Set([...locals, landedIn]); + // branch -> where it lands, so the chain can be walked without re-reading. + const branchTarget: Record = {}; + for (const t of trees) { + const entry = t.branch ? all[resolve(t.path)] : undefined; + if (t.branch && entry) branchTarget[t.branch] = entry.target; + } + branchTarget[goneBranch] = landedIn; + const resolved = resolveThroughChain(landedIn, { + live, + branchTarget, + defaultBranch: null, + }); + if (!resolved) return; + for (const [path] of affected) { + // `expect: goneBranch` — only hand down a worktree that is STILL aiming at the + // branch we just tidied away. The git reads above are async, so a user's + // `worktree.setTarget` can land in between; their explicit choice wins. + if (!putTarget(file, path, resolved, { retargetedFrom: goneBranch, expect: goneBranch })) { + // Best-effort: the branch is already gone, so we cannot fail the wrap-up. + // Either the store is not writable or the target moved under us; log so the + // former is observable instead of leaving the child silently aimed at the + // deleted branch with no trace. + log.warn( + `[makit] handing ${path} down to ${resolved} was not persisted (store not writable, or its target changed meanwhile)`, + ); + } + } + } + /** * Remove a worktree. Validates the path belongs to the project and is not the * primary checkout *before* touching anything, then runs @@ -1158,6 +1346,25 @@ export class SessionManager extends EventEmitter { // succeeds. Killing first would orphan sessions (unrecoverably) if the // removal then failed, leaving the worktree on disk without its sessions. await removeWorktree(repoPath, worktreePath, true); + // Forget this worktree's target. Not housekeeping -- correctness: worktree + // paths are derived deterministically as `//`, + // so removing a worktree and creating another with the same name reuses the + // path. A surviving entry would silently hand the NEW worktree the dead + // one's merge destination. Runs after git succeeds, so a failed removal + // keeps its target. (A worktree removed outside makit, e.g. by `git + // worktree remove`, leaves a stale entry behind; that is safe because an + // unresolvable target now surfaces as `targetResolved: false` rather than a + // silent partial count -- but it is why the store must never be treated as + // authoritative without resolution.) + if (!clearTarget(worktreeTargetsFile(), target)) { + // The worktree is already removed, so this cannot fail the operation. Log + // so a failed delete (full/read-only store) is observable: a surviving + // stale entry could otherwise hand a recreated worktree at the same + // deterministic path the dead one's target. + log.warn( + `[makit] removed worktree ${target} but could not clear its stored target (store not writable)`, + ); + } // Reconcile sessions bound to the removed worktree (SPEC-29): // - closed → leave as-is (already preserved; it simply becomes orphaned) // - draft → kill (no transcript to keep; must not launch in a deleted dir) @@ -1549,8 +1756,14 @@ export class SessionManager extends EventEmitter { * runs this native session/thread id. */ private toSessionListItem(info: AgentSessionInfo, agent: string): AgentSessionListItem { + // `cold` (== holds a `DetachedAdapter`) is the honest predicate for "no live + // agent", and it is the only one that covers every case: a CLOSED session + // (SPEC-29) and a REHYDRATED one after a server restart both keep their + // `agentSessionId` but hold a `DetachedAdapter` — and rehydration leaves + // `closed === false`, so a `!closed` check would still report a process-less + // session as attached and suppress the attach/resume affordance for it. const attached = [...this.sessions.values()].some( - (s) => s.agentSessionId === info.id, + (s) => s.agentSessionId === info.id && !s.cold, ); return { piSessionId: info.id, @@ -1576,7 +1789,31 @@ export class SessionManager extends EventEmitter { const existingId = this.attachedByPi.get(piSessionId); if (existingId) { const existing = this.sessions.get(existingId); - if (existing) return existing; + if (existing) { + if (!existing.closed) return existing; + // Reviving a CLOSED session must go through the SAME in-flight dedupe as a + // fresh attach. `reopenSession` clears `closed` before `reattachSession` + // finishes `start()`, so an uncoalesced second caller would see + // `closed === false`, return immediately, and send to an adapter that has + // not finished initialising. + const revivePending = this.attachInFlight.get(piSessionId); + if (revivePending) return revivePending; + const revive = (async () => { + await this.reopenSession(existingId); + // `reattachSession`, NOT `ensureLive`: `ensureLive` deliberately swallows + // a failed resume (it is called speculatively on subscribe), which would + // let this method hand back a cold `DetachedAdapter` session as though it + // had resumed live. An explicit attach request owns its failure. + await this.reattachSession(existingId); + return existing; + })(); + this.attachInFlight.set(piSessionId, revive); + try { + return await revive; + } finally { + this.attachInFlight.delete(piSessionId); + } + } this.attachedByPi.delete(piSessionId); } @@ -1781,9 +2018,22 @@ export class SessionManager extends EventEmitter { // Seed history BEFORE the adapter goes live so it precedes new events. if (opts.backfill && opts.backfill.length > 0) session.backfill(opts.backfill); - await activeAdapter.start( - this.startOpts(project.dto.path, session.id, opts.resumeSessionPath), - ); + try { + await activeAdapter.start(this.startOpts(project.dto.path, session.id, opts.resumeSessionPath)); + } catch (e) { + // `start()` can spawn/handshake a child and then fail (e.g. model config), + // exactly as the reattach path documents. This adapter was never registered + // in `this.sessions`, so nothing else will ever reap it — kill the + // half-started child before propagating, or it leaks a live agent process. + try { + await activeAdapter.kill(); + } catch (killErr) { + log.warn( + `[makit] createSession(${session.id.slice(0, 8)}): stopping the half-started agent failed: ${reason(killErr)}`, + ); + } + throw e; + } // Persist the live adapter's native session/thread id for restart-resume. session.captureAgentSessionId(); this.sessions.set(session.id, session); diff --git a/server/src/pr_watcher.test.ts b/server/src/pr_watcher.test.ts index 66247f96..02e27b4f 100644 --- a/server/src/pr_watcher.test.ts +++ b/server/src/pr_watcher.test.ts @@ -41,6 +41,9 @@ function repos(branch: string, prInfo: PullRequestInfo | null): RepoDTO[] { path: "/wt", branch, isPrimary: false, + targetBranch: "main", + targetResolved: true, + retargetedFrom: null, insertions: 0, deletions: 0, filesChanged: 0, diff --git a/server/src/protocol.ts b/server/src/protocol.ts index e5c13fce..8213d0d5 100644 --- a/server/src/protocol.ts +++ b/server/src/protocol.ts @@ -767,6 +767,38 @@ export interface WorktreeDTO { path: string; branch: string | null; isPrimary: boolean; + /** + * The branch this worktree's work lands in: what the diff below measures + * against (`git diff target...HEAD`, i.e. what a PR into it would contain), + * what `gh pr create --base` targets, and what a wrap-up fast-forwards. + * + * Null for the primary checkout (it *is* where branches land) and for a + * detached worktree (no branch to land). Resolved by `resolveTargetBranch`: + * an open PR's `baseRefName` outranks the persisted user choice, which + * outranks the repo default. + */ + targetBranch: string | null; + /** + * False when {@link targetBranch} could not be resolved (deleted, never + * fetched), meaning the diff below is a working-tree-only figure and the + * committed delta is simply unknown. + * + * Clients MUST suppress the +/- pill in that case rather than render the + * numbers: the failure mode is not a zero but a *plausible small* count, which + * reads as "barely diverged" on a worktree that may be far ahead. + */ + targetResolved: boolean; + /** + * The target this one replaced, when makit changed it automatically — the branch + * we were aiming at vanished without a wrap-up, so we fell back to the repo + * default (or to wherever the chain actually landed). + * + * Present so the change can be **announced**: a silent repoint moves a + * worktree's diff and its future pull request to a different destination, and + * doing that invisibly is how someone opens a PR against the wrong branch. Goes + * away once the user picks a target explicitly. + */ + retargetedFrom: string | null; insertions: number; deletions: number; filesChanged: number; @@ -986,6 +1018,10 @@ export type CmdKind = | "queue.promote" // repos / projects / worktrees | "worktree.create" + /** Set the branch a worktree's work lands in (diff base, PR base, ff target). */ + | "worktree.setTarget" + /** Ranked candidates for the "Lands in" picker (read-only, no broadcast). */ + | "worktree.targetCandidates" | "worktree.createFromPr" | "worktree.remove" | "worktree.wrapUp" diff --git a/server/src/repo_service.test.ts b/server/src/repo_service.test.ts index 75db2280..6445c596 100644 --- a/server/src/repo_service.test.ts +++ b/server/src/repo_service.test.ts @@ -1,7 +1,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { enrichPrs } from "./repo_service.js"; +import { + enrichPrs, + resolveTargetBranch, + repointVanishedTargets, +} from "./repo_service.js"; import type { GithubGateway, PrLookup } from "./github/gateway.js"; import type { PullRequestDTO, RepoDTO } from "./protocol.js"; import type { PullRequestInfo } from "./git.js"; @@ -41,6 +45,9 @@ function repos(branch: string): RepoDTO[] { path: "/wt", branch, isPrimary: false, + targetBranch: "main", + targetResolved: true, + retargetedFrom: null, insertions: 0, deletions: 0, filesChanged: 0, @@ -105,3 +112,304 @@ test("a fresh PR lookup is written without the stale flag", async () => { assert.equal(result!.number, 7); assert.ok(!result!.stale, "a successful re-fetch is not stale"); }); + +// ───────────────────────────────────────────────────────────────────────────── +// resolveTargetBranch (§0 B3, B6, B7) +// +// One resolver owns precedence, and it must run BEFORE `diffStat` — otherwise +// the pill's numbers come from the persisted value while the label comes from +// the PR, inside a single broadcast. +// ───────────────────────────────────────────────────────────────────────────── + +test("resolveTargetBranch: the primary checkout has no target", () => { + // It is where branches land, not one that lands. + assert.equal( + resolveTargetBranch({ + branch: "main", + isPrimary: true, + prBaseRefName: "release/1.4", + persisted: "release/1.4", + defaultBranch: "main", + }), + null, + ); +}); + +test("resolveTargetBranch: a detached worktree has no target", () => { + assert.equal( + resolveTargetBranch({ + branch: null, + isPrimary: false, + prBaseRefName: "main", + persisted: "main", + defaultBranch: "main", + }), + null, + ); +}); + +test("resolveTargetBranch: an open PR's baseRefName outranks the persisted value", () => { + // The forge is authoritative while a PR is LIVE — which is also how we inherit + // GitHub's automatic PR retargeting for free instead of reimplementing it. + assert.equal( + resolveTargetBranch({ + branch: "feat/stack-b", + isPrimary: false, + prBaseRefName: "main", + prState: "OPEN", + persisted: "feat/parent", + defaultBranch: "main", + }), + "main", + ); +}); + +test("resolveTargetBranch: the persisted value wins when there is no PR", () => { + assert.equal( + resolveTargetBranch({ + branch: "feat/stack-b", + isPrimary: false, + prBaseRefName: null, + persisted: "feat/parent", + defaultBranch: "main", + }), + "feat/parent", + ); +}); + +test("resolveTargetBranch: falls back to the repo default when nothing is stored", () => { + // B6: this is also the upgrade seed — it reproduces today's behaviour exactly, + // so shipping the feature moves nobody's numbers until they choose. + assert.equal( + resolveTargetBranch({ + branch: "feat/stack-b", + isPrimary: false, + prBaseRefName: null, + persisted: null, + defaultBranch: "main", + }), + "main", + ); +}); + +test("resolveTargetBranch: a target equal to the worktree's own branch is ignored", () => { + // Reachable via `renameBranch`, which keeps the path (and therefore the stored + // target) while changing the branch name. Self-targeting would silently make + // diffStat report working-tree-only, so fall through instead. + assert.equal( + resolveTargetBranch({ + branch: "feat/parent", + isPrimary: false, + prBaseRefName: null, + persisted: "feat/parent", + defaultBranch: "main", + }), + "main", + ); +}); + +test("resolveTargetBranch: returns null rather than self even via the default", () => { + assert.equal( + resolveTargetBranch({ + branch: "main", + isPrimary: false, + prBaseRefName: null, + persisted: null, + defaultBranch: "main", + }), + null, + ); +}); + +test("resolveTargetBranch: empty strings are treated as unset", () => { + assert.equal( + resolveTargetBranch({ + branch: "feat/x", + isPrimary: false, + prBaseRefName: "", + persisted: "", + defaultBranch: "main", + }), + "main", + ); +}); + +test("resolveTargetBranch: no default and nothing stored yields null", () => { + assert.equal( + resolveTargetBranch({ + branch: "feat/x", + isPrimary: false, + prBaseRefName: null, + persisted: null, + defaultBranch: null, + }), + null, + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// B7: PR lifecycle. Only a LIVE pull request is authoritative about where work +// lands — a merged or closed one is history, and letting its base keep +// overriding would pin a worktree to a destination that is already settled. +// ───────────────────────────────────────────────────────────────────────────── + +test("resolveTargetBranch: only an OPEN pull request outranks the persisted value", () => { + const args = { + branch: "feat/stack-b", + isPrimary: false, + persisted: "feat/parent", + defaultBranch: "main", + }; + // Live: the forge wins. + assert.equal( + resolveTargetBranch({ ...args, prBaseRefName: "main", prState: "OPEN" }), + "main", + ); + // Merged/closed: history. The user's own value takes over again. + assert.equal( + resolveTargetBranch({ ...args, prBaseRefName: "main", prState: "MERGED" }), + "feat/parent", + ); + assert.equal( + resolveTargetBranch({ ...args, prBaseRefName: "main", prState: "CLOSED" }), + "feat/parent", + ); +}); + +test("resolveTargetBranch: an unknown PR state is treated as not authoritative", () => { + // Forward compatibility: a state this build does not recognise must not be + // allowed to silently redirect where work lands. + assert.equal( + resolveTargetBranch({ + branch: "feat/x", + isPrimary: false, + prBaseRefName: "release/9", + prState: "SOMETHING_NEW", + persisted: "feat/parent", + defaultBranch: "main", + }), + "feat/parent", + ); +}); + +test("resolveTargetBranch: state is matched case-insensitively", () => { + assert.equal( + resolveTargetBranch({ + branch: "feat/x", + isPrimary: false, + prBaseRefName: "main", + prState: "open", + persisted: "feat/parent", + defaultBranch: "main", + }), + "main", + ); +}); + +// repointVanishedTargets — the pure core of the vanished-target repair. Bugs +// pinned here: cross-repo corruption, respecting an open PR's (possibly +// remote-only) base as live, and never persisting a self-target. + +test("repointVanishedTargets: a genuinely vanished target repoints to the default", () => { + const writes = repointVanishedTargets({ + here: new Set(["/wt"]), + persisted: { "/wt": { target: "feat/gone" } }, + live: new Set(["main"]), + branchTarget: {}, + ownBranch: { "/wt": "feat/child" }, + defaultBranch: "main", + }); + assert.deepEqual(writes, [{ path: "/wt", target: "main", retargetedFrom: "feat/gone" }]); +}); + +test("repointVanishedTargets: leaves another repo's persisted target untouched", () => { + // `/other` belongs to a different repo (not in `here`); its target does not + // exist among THIS repo's branches, but it must never be rewritten from here. + const writes = repointVanishedTargets({ + here: new Set(["/wt"]), + persisted: { + "/wt": { target: "main" }, + "/other": { target: "some-other-repo-branch" }, + }, + live: new Set(["main"]), + branchTarget: {}, + ownBranch: { "/wt": "feat/child" }, + defaultBranch: "main", + }); + assert.deepEqual(writes, [], "no writes: /wt is fine and /other is not ours"); +}); + +test("repointVanishedTargets: a live target (e.g. an open PR base) is not clobbered", () => { + // The caller adds an open PR's base to `live` even when it exists only on the + // remote, so it is NOT treated as vanished. + const writes = repointVanishedTargets({ + here: new Set(["/wt"]), + persisted: { "/wt": { target: "release/1.4" } }, + live: new Set(["main", "release/1.4"]), + branchTarget: {}, + ownBranch: { "/wt": "feat/child" }, + defaultBranch: "main", + }); + assert.deepEqual(writes, [], "a live base is not repointed"); +}); + +test("repointVanishedTargets: never persists a self-target (mutual stack)", () => { + // W is on feat/x, targets feat/y; feat/y targets feat/x; feat/y is deleted. + // The chain resolves feat/y -> feat/x, which is W's OWN branch. Persisting that + // would record a value resolveTargetBranch discards on every read. + const writes = repointVanishedTargets({ + here: new Set(["/wt-x"]), + persisted: { "/wt-x": { target: "feat/y" } }, + live: new Set(["feat/x", "main"]), + branchTarget: { "feat/y": "feat/x" }, + ownBranch: { "/wt-x": "feat/x" }, + defaultBranch: "main", + }); + assert.deepEqual(writes, [], "a resolution onto the worktree's own branch is skipped"); +}); + +test("repointVanishedTargets: leaves a broken target in place when nothing resolves", () => { + const writes = repointVanishedTargets({ + here: new Set(["/wt"]), + persisted: { "/wt": { target: "feat/gone" } }, + live: new Set(["unrelated"]), + branchTarget: {}, + ownBranch: { "/wt": "feat/child" }, + defaultBranch: null, + }); + assert.deepEqual(writes, [], "no default to fall back to: surface targetResolved:false instead"); +}); + +test("repointVanishedTargets: repairs to a remote-only, origin/-qualified default", () => { + // `resolveDefaultBranch` returns a remote-only default QUALIFIED (`origin/release`) + // because git cannot resolve a bare name against `refs/remotes/origin/`, while + // `listRemoteBranchNames` strips the prefix. The caller therefore records BOTH + // spellings in `live`; with only the bare one, `resolveThroughChain` rejected a + // live default as "gone" and the repair was skipped, leaving a broken target. + const writes = repointVanishedTargets({ + here: new Set(["/wt"]), + persisted: { "/wt": { target: "feat/gone" } }, + live: new Set(["release", "origin/release"]), + branchTarget: {}, + ownBranch: { "/wt": "feat/child" }, + defaultBranch: "origin/release", + }); + assert.deepEqual(writes, [ + { path: "/wt", target: "origin/release", retargetedFrom: "feat/gone" }, + ]); +}); + +test("repointVanishedTargets: a default absent from `live` is never invented", () => { + // Pins WHY the caller must record both spellings: a `live` set carrying only the + // bare `release` cannot honour an `origin/release` default, and inventing a + // destination is worse than reporting `targetResolved: false`. + const writes = repointVanishedTargets({ + here: new Set(["/wt"]), + persisted: { "/wt": { target: "feat/gone" } }, + live: new Set(["release"]), + branchTarget: {}, + ownBranch: { "/wt": "feat/child" }, + defaultBranch: "origin/release", + }); + assert.deepEqual(writes, []); +}); diff --git a/server/src/repo_service.ts b/server/src/repo_service.ts index d8647572..363fe439 100644 --- a/server/src/repo_service.ts +++ b/server/src/repo_service.ts @@ -24,6 +24,8 @@ export type RepoSettingsLookup = (project: ProjectDTO) => RepoSettingsDTO | unde import { isGitRepo, resolveDefaultBranch, + listLocalBranches, + listRemoteBranchNames, detectCurrentBranch, listWorktrees, diffStat, @@ -33,6 +35,13 @@ import { commitsBehind, } from "./git.js"; import { mapLimit } from "./concurrency.js"; +import { + loadTargets, + putTarget, + worktreeTargetsFile, + type TargetMap, +} from "./worktree-target-store.js"; +import { resolveThroughChain } from "./target_candidates.js"; /** * Accessor for the previously-broadcast PR of a worktree, so a failed re-fetch @@ -67,6 +76,269 @@ const PR_CONCURRENCY = 6; * result to add PR info without redoing the git work — so the numbers never * wait on the network. */ +/** + * Whether a cached pull request is **authoritative** about where its branch + * lands: it exists, it is OPEN, and it is fresh. + * + * `stale` matters as much as the state. `enrichPrs` deliberately retains the + * last-known PR when a lookup could not complete, so during a GitHub outage a + * stale record would otherwise let an unverified — possibly closed or + * since-retargeted — base overwrite a target the user just chose. + * + * One predicate, because two sites encoded it independently and a change to one + * silently diverged from the other. + */ +function isLivePr(pr: PullRequestDTO | null): pr is PullRequestDTO { + return pr !== null && !pr.stale && pr.state?.toUpperCase() === "OPEN"; +} + +/** + * Persist the base of any **live** pull request that disagrees with what we have + * stored, and return the effective map. + * + * This is what makes the "PR wins while it is open, the stored choice applies + * otherwise" rule survive its own transitions. Three cases it fixes: + * + * * a PR opened by hand against a different base (`gh pr create --base …`) — the + * stored value catches up rather than lying in wait, + * * the PR closing or reopening — the fallback is now where it actually pointed, + * * GitHub auto-retargeting a stacked PR and then auto-closing it, which would + * otherwise drop us back onto a target that no longer matches. + * + * Announced via `retargetedFrom` only when it OVERRODE a value we already had: + * agreement is not news, and a first-time adoption of the base the user chose + * anyway would just be noise. + * + * Synchronous: `lastKnown` is the previous broadcast's PR, already in memory. It + * is null on the very first snapshot after a restart, so adoption happens one + * poll later -- the documented latency window, not a lost update. + */ +function adoptLivePrTargets( + repoPath: string, + entries: readonly { path: string; branch: string | null }[], + persisted: TargetMap, + lastKnown: LastKnownPr, +): TargetMap { + let out: TargetMap | null = null; + const file = worktreeTargetsFile(); + for (const e of entries) { + if (!e.branch) continue; + const pr = lastKnown(repoPath, e.branch); + // Only a FRESH, live PR is authoritative. A `stale` PR is last-known data + // retained by `enrichPrs` when a lookup could not complete; adopting its base + // during a GitHub outage could overwrite a freshly-chosen user target with an + // unverified — possibly closed or since-retargeted — base. + if (!isLivePr(pr)) continue; + const base = pr.baseRefName; + // A PR cannot land in its own head branch; treat that as bad data rather than + // persisting a self-target we would then have to discard on every read. + if (!base || base === e.branch) continue; + const current = persisted[e.path]?.target; + if (current === base) continue; + // Reflect the adoption in the returned map ONLY if it actually persisted, so a + // full/read-only store cannot make this snapshot report a base it never saved. + // `expect` guards the window between `loadTargets` (once, in `listRepos`) and + // this write: the git reads above are async, so a user's `worktree.setTarget` + // can land in between — and their explicit choice must not be overwritten by a + // decision made from the stale map. + if (!putTarget(file, e.path, base, { ...(current ? { retargetedFrom: current } : {}), expect: current ?? null })) + continue; + out ??= { ...persisted }; + out[e.path] = current ? { target: base, retargetedFrom: current } : { target: base }; + } + return out ?? persisted; +} + +/** + * Pure decision core of {@link repairVanishedTargets}: given the live branch set + * and the record of what-lands-where, decide the new target for each of THIS + * repo's worktrees whose persisted target has vanished. Returns the writes to + * persist (empty when nothing is broken), so the I/O stays in the caller and + * this stays unit-testable without a repo. + * + * Two invariants it enforces, both once-live bugs: + * * **Repo isolation** — `persisted` is the GLOBAL store; only paths in `here` + * (this repo's worktrees) are considered, so another repo's target is never + * tested against this repo's branches and rewritten to this repo's default. + * * **Remote and open-PR bases are live** — the caller adds `origin` branches and + * the base of any OPEN PR to `live`, so a target that exists on the remote but + * was never checked out locally is not rewritten to the default. See the + * caller for why a delayed repair beats a silent redirect. + */ +export function repointVanishedTargets(args: { + here: ReadonlySet; + persisted: TargetMap; + live: ReadonlySet; + branchTarget: Readonly>; + ownBranch: Readonly>; + defaultBranch: string | null; +}): Array<{ path: string; target: string; retargetedFrom: string }> { + const { here, persisted, live, branchTarget, ownBranch, defaultBranch } = args; + const out: Array<{ path: string; target: string; retargetedFrom: string }> = []; + for (const [path, entry] of Object.entries(persisted)) { + if (!entry.target || !here.has(path)) continue; + if (live.has(entry.target)) continue; + const resolved = resolveThroughChain(entry.target, { live, branchTarget, defaultBranch }); + // Nothing to fall back to (no default, or it is gone too). Leave the broken + // target in place: `diffStat` reports `targetResolved: false` and the UI says + // so, which beats inventing a destination. + if (!resolved || resolved === entry.target) continue; + // A resolution onto the worktree's OWN branch is a self-target (reachable via + // a mutual stack: W→feat/y, feat/y→W's branch, feat/y deleted). `resolveTargetBranch` + // discards a self-target on every read, so persisting it would only record a + // value that is never used and announce a move that never took effect — the + // same guard `adoptLivePrTargets` already applies. + if (resolved === ownBranch[path]) continue; + out.push({ path, target: resolved, retargetedFrom: entry.target }); + } + return out; +} + +/** + * Repoint any worktree whose persisted target no longer exists, and return the + * effective map. Pure with respect to its inputs; writes to the store only when a + * repair was needed. + * + * Uses {@link resolveThroughChain} so a stack that landed bottom-up outside makit + * still collapses to where it actually landed rather than jumping straight to the + * repo default. + */ +async function repairVanishedTargets( + repoPath: string, + entries: readonly { path: string; branch: string | null }[], + persisted: TargetMap, + defaultBranch: string | null, + lastKnown: LastKnownPr, +): Promise { + // Only this repo's worktrees carry targets we may touch; `persisted` is global. + const here = new Set(entries.map((e) => e.path)); + const hasCandidates = Object.entries(persisted).some(([path, e]) => e.target && here.has(path)); + if (!hasCandidates) return persisted; + const locals = await listLocalBranches(repoPath); + if (locals.length === 0) return persisted; + // Liveness is local refs ∪ `origin` refs ∪ the bases of currently-OPEN PRs. + // + // This trade-off has been argued both ways, so it is settled here explicitly: + // including `origin` means a stale `origin/` left behind after a merged + // branch is auto-deleted DELAYS the repair until the next `fetch --prune`. That + // is strictly better than the alternative. Excluding it silently REDIRECTS a + // worktree whose target lives only on the remote — e.g. an open PR into a + // remote-only `release` is adopted, the PR closes, and the target is rewritten + // to the repo default, moving future diffs and PR bases without asking. A + // delayed repair shows the honest `targetResolved: false` (the branch is real, + // it just is not here yet); a wrong redirect is unrecoverable data loss of the + // user's intent. Never trade the second for the first. + const remotes = await listRemoteBranchNames(repoPath); + const live = new Set(locals); + for (const b of remotes) { + // BOTH spellings. `listRemoteBranchNames` strips the prefix, but a stored + // target (and `resolveDefaultBranch`'s answer for a remote-only branch) is the + // QUALIFIED `origin/` — git cannot resolve a bare name against + // `refs/remotes/origin/`. Recording only one form left `resolveThroughChain` + // rejecting a perfectly live default as "gone" and skipping the repair. + live.add(b); + live.add(`origin/${b}`); + } + for (const e of entries) { + if (!e.branch) continue; + const pr = lastKnown(repoPath, e.branch); + if (isLivePr(pr) && pr.baseRefName) { + live.add(pr.baseRefName); + } + } + + // branch -> where it lands, and path -> its own branch, so a multi-link chain + // can be walked and a self-target rejected without re-reading. + const branchTarget: Record = {}; + const ownBranch: Record = {}; + for (const e of entries) { + if (!e.branch) continue; + ownBranch[e.path] = e.branch; + const entry = persisted[e.path]; + if (entry) branchTarget[e.branch] = entry.target; + } + + const writes = repointVanishedTargets({ + here, + persisted, + live, + branchTarget, + ownBranch, + defaultBranch, + }); + if (writes.length === 0) return persisted; + const out: TargetMap = { ...persisted }; + const file = worktreeTargetsFile(); + for (const w of writes) { + // Reflect the repair in the returned map ONLY when it persisted, so a + // full/read-only store cannot make this snapshot report a repair that the + // next read will contradict. `expect` is the vanished target the decision was + // based on, so a user's `setTarget` landing during the git reads above wins. + if ( + !putTarget(file, w.path, w.target, { + retargetedFrom: w.retargetedFrom, + expect: w.retargetedFrom, + }) + ) + continue; + out[w.path] = { target: w.target, retargetedFrom: w.retargetedFrom }; + } + return out; +} + +/** + * The single owner of "what branch does this worktree land in?". + * + * Precedence, and why: + * 1. **Primary or detached -> null.** The primary checkout *is* where branches + * land; a detached worktree has no branch to land. Neither has a target, and + * `diffStat` reads a null target as "count the working tree", which is the + * honest answer for both. + * 2. **An open PR's `baseRefName`.** Once a PR exists the forge is + * authoritative -- and this is precisely how makit inherits GitHub's + * automatic PR retargeting (when a parent PR merges and its branch is + * deleted, GitHub repoints the children at the merged PR's own base) without + * reimplementing any of it. + * 3. **The persisted user choice** (`worktree-target-store`): the answer given + * at creation time or via `worktree.setTarget`. + * 4. **The repo default.** Deliberately last, and deliberately the fallback: + * it reproduces the pre-feature behaviour exactly, so upgrading an existing + * install moves nobody's numbers until they choose a target. + * + * A winner equal to the worktree's own branch is discarded and resolution + * continues. That is reachable in practice: `renameBranch` keeps the worktree + * path (and therefore its stored target) while changing the branch name, so a + * rename onto the target's name would otherwise leave the worktree silently + * self-targeting -- which `diffStat` would report as working-tree-only with no + * indication anything was wrong. + * + * Pure, so the precedence rules are testable without a repo or a network. + */ +export function resolveTargetBranch(args: { + branch: string | null; + isPrimary: boolean; + prBaseRefName: string | null | undefined; + /** + * The pull request's state. Only a **live** PR is authoritative: a merged or + * closed one is history, and letting its base keep winning would pin the + * worktree to a destination that is already settled — so after it ends, the + * user's own value takes over again. An unrecognised state is treated as not + * authoritative, so a state this build predates cannot silently redirect where + * work lands. + */ + prState?: string | null; + persisted: string | null | undefined; + defaultBranch: string | null; +}): string | null { + const { branch, isPrimary, prBaseRefName, prState, persisted, defaultBranch } = args; + if (isPrimary || !branch) return null; + const livePrBase = prState?.toUpperCase() === "OPEN" ? prBaseRefName : null; + for (const candidate of [livePrBase, persisted, defaultBranch]) { + if (candidate && candidate !== branch) return candidate; + } + return null; +} + export async function listRepos( projects: ProjectDTO[], sessions: Session[], @@ -76,13 +348,33 @@ export async function listRepos( settingsFor?: RepoSettingsLookup, ): Promise { // Bounded fan-out across projects (SPEC-17 P3 × #66 concurrency cap). + // Read the persisted targets ONCE per snapshot rather than per worktree: it is + // a single small JSON file, and re-reading it inside the fan-out would turn + // one read into N. + // + // Deliberately does NOT prune the target store here. This is a read path (the + // snapshot is returned, not mutated), and pruning against the live worktree set + // from here is unsafe: a transient `isGitRepo`/`listWorktrees` failure reports + // an empty repo and would delete that repo's real targets, and a worktree + // created concurrently (between enumeration and the sweep) would lose its + // freshly persisted target. Stale entries are already harmless — `removeWorktree` + // clears its own, `createWorktree` overwrites a reused path, and a target with + // no branch surfaces as `targetResolved: false` rather than a silent wrong + // number — so pruning buys nothing that offsets that risk. + const persistedTargets = loadTargets(worktreeTargetsFile()); const repos = await mapLimit(projects, PROJECT_CONCURRENCY, async (p) => { // Settings are resolved BEFORE the snapshot because the snapshot needs one of // them: `defaultBranch` is the base every diff +/- number and ahead count is // measured against, so an override that arrived only in the settings blob would // leave the row claiming one base while the numbers used another. const settings = settingsFor?.(p); - const repo = await repoSnapshot(p, sessions, settings?.defaultBranch?.value); + const repo = await repoSnapshot( + p, + sessions, + lastKnown, + persistedTargets, + settings?.defaultBranch?.value, + ); return settings === undefined ? repo : { ...repo, settings }; }); return includePrs ? enrichPrs(repos, gateway, lastKnown) : repos; @@ -98,6 +390,8 @@ export async function listRepos( async function repoSnapshot( dto: ProjectDTO, sessions: Session[], + lastKnown: LastKnownPr, + persistedTargets: TargetMap, defaultBranchOverride?: string, ): Promise { const repoPath = dto.path; @@ -130,20 +424,70 @@ async function repoSnapshot( sessionsByPath.set(key, list); } + // Rule 4: a target that has vanished without makit tidying it away. + // + // Someone ran `git branch -D`, or the forge auto-deleted the head branch when a + // pull request merged -- either way there was no wrap-up, so nothing handed a + // replacement down (rule 3). From here "merged" and "abandoned" are + // indistinguishable, so we take the simple, predictable route: fall back to the + // repo default, and RECORD what it used to be so the change is announced rather + // than done behind the user's back. + // + // Repairing during a read is deliberate: this is the only place we notice, and + // it costs one branch listing per repo (not per worktree). It writes only when + // something actually changed, so a healthy repo never touches the file. + // B7: adopt a LIVE pull request's base into the persisted value, so the two + // backing stores converge instead of disagreeing at every lifecycle edge. + // Without this, closing a PR falls back to whatever was persisted BEFORE it + // existed -- a value that has not been true since the PR was opened -- and + // GitHub's auto-retarget-then-auto-close sequence lands us on a target that no + // longer matches reality. + const adopted = gitRepo ? adoptLivePrTargets(repoPath, entries, persistedTargets, lastKnown) : persistedTargets; + const repaired = gitRepo + ? await repairVanishedTargets(repoPath, entries, adopted, defaultBranch, lastKnown) + : adopted; + const worktrees: WorktreeDTO[] = await mapLimit(entries, WORKTREE_CONCURRENCY, async (e) => { // Run the per-worktree git probes sequentially so they add no extra // parallel fan-out on top of WORKTREE_CONCURRENCY: at most one of these // helpers runs at a time per worktree (diffStat's own internal parallelism // is unchanged), keeping concurrent git subprocesses within budget. - const stat = await diffStat(e.path, defaultBranch); + // Resolve the target FIRST, so the numbers and the label can never disagree. + // `enrichPrs` runs later and only assigns `w.pr` -- it never recomputes the + // diff -- so deferring this to that pass would leave the pill measuring + // against the persisted value while the UI showed the PR's base, inside one + // broadcast. `lastKnown` hands us the previous poll's PR synchronously, + // which is what makes PR-first precedence possible in the git-only phase. + const knownPr = e.branch ? lastKnown(repoPath, e.branch) : null; + const targetBranch = resolveTargetBranch({ + branch: e.branch, + isPrimary: e.isPrimary, + prBaseRefName: knownPr?.baseRefName, + prState: knownPr?.state, + persisted: repaired[e.path]?.target, + defaultBranch, + }); + const stat = await diffStat(e.path, targetBranch); const uncommittedFiles = await uncommittedFileCount(e.path); - const aheadCount = await commitsAhead(e.path, defaultBranch); + // NOTE: ahead/behind are UPSTREAM metrics, not target metrics. + // `commitsAhead` prefers `@{upstream}..HEAD` and only falls back to the ref + // passed here when the branch has no upstream; `commitsBehind` takes no ref + // at all. Retargeting therefore moves the diff and NOT these counts -- which + // is correct, because they answer "what would a push send / a pull fetch", + // not "what would a PR contain". Do not present them as target-relative. + const aheadCount = await commitsAhead(e.path, targetBranch); const behindCount = await commitsBehind(e.path); return { id: e.path, path: e.path, branch: e.branch, isPrimary: e.isPrimary, + targetBranch, + targetResolved: stat.targetResolved, + // What this target replaced, when makit moved it automatically. Present + // until the user picks a target explicitly, which is what makes it an + // announcement rather than a toast that can be missed. + retargetedFrom: repaired[e.path]?.retargetedFrom ?? null, insertions: stat.insertions, deletions: stat.deletions, filesChanged: stat.filesChanged, diff --git a/server/src/target_candidates.test.ts b/server/src/target_candidates.test.ts new file mode 100644 index 00000000..d4c742cb --- /dev/null +++ b/server/src/target_candidates.test.ts @@ -0,0 +1,382 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { targetCandidates, PREVIEW_LIMIT, resolveThroughChain } from "./target_candidates.js"; +import { addWorktree } from "./git.js"; + +/** + * A repo with a real two-level stack plus extra branches, so ranking has + * something to discriminate: + * main (default) + * feat/parent forked off main, has a commit, checked out in a worktree + * feat/child forked off feat/parent, has a commit <- the subject + * zz-other a plain local branch, no worktree + */ +async function makeStack(): Promise<{ + repo: string; + base: string; + child: string; + cleanup: () => void; +}> { + const repo = mkdtempSync(join(tmpdir(), "makit-cand-repo-")); + const base = mkdtempSync(join(tmpdir(), "makit-cand-wt-")); + const g = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd }); + g(repo, "init", "-q", "-b", "main"); + g(repo, "config", "user.email", "t@t.io"); + g(repo, "config", "user.name", "Test"); + writeFileSync(join(repo, "README.md"), "base\n"); + g(repo, "add", "."); + g(repo, "commit", "-q", "-m", "initial"); + + const parent = await addWorktree({ + repoPath: repo, + name: "parent", + branch: "feat/parent", + startPoint: "main", + baseDir: base, + }); + writeFileSync(join(parent, "p.txt"), "p1\np2\n"); + g(parent, "add", "."); + g(parent, "commit", "-q", "-m", "parent"); + + const child = await addWorktree({ + repoPath: repo, + name: "child", + branch: "feat/child", + startPoint: "feat/parent", + baseDir: base, + }); + writeFileSync(join(child, "c.txt"), "c1\n"); + g(child, "add", "."); + g(child, "commit", "-q", "-m", "child"); + + g(repo, "branch", "zz-other", "main"); + + return { + repo, + base, + child, + cleanup: () => { + rmSync(repo, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + }, + }; +} + +test("ranks forkedFrom first, then default, then other worktrees, then the rest", async () => { + const s = await makeStack(); + try { + const cands = await targetCandidates(s.repo, s.child); + const order = cands.map((c) => `${c.group}:${c.branch}`); + assert.deepEqual(order, [ + "forkedFrom:feat/parent", + "default:main", + "other:zz-other", + "other:feat/child", + ]); + } finally { + s.cleanup(); + } +}); + +test("a branch checked out in another worktree ranks in the worktree group", async () => { + const s = await makeStack(); + try { + // A sibling worktree on a branch that is neither the child's fork point nor + // the repo default — the only thing that lands in the `worktree` group. + await addWorktree({ + repoPath: s.repo, + name: "sibling", + branch: "feat/sibling", + startPoint: "main", + baseDir: s.base, + }); + const cands = await targetCandidates(s.repo, s.child); + const sib = cands.find((c) => c.branch === "feat/sibling")!; + assert.equal(sib.group, "worktree", "a sibling worktree's branch is group 3"); + const iSib = cands.findIndex((c) => c.branch === "feat/sibling"); + const iOther = cands.findIndex((c) => c.branch === "zz-other"); + assert.ok(iSib < iOther, "the worktree group ranks ahead of the plain other group"); + } finally { + s.cleanup(); + } +}); + +test("the worktree's own branch is offered but flagged, and sorts last in its group", async () => { + const s = await makeStack(); + try { + const cands = await targetCandidates(s.repo, s.child); + const self = cands.find((c) => c.branch === "feat/child")!; + assert.equal(self.isSelf, true, "must be flagged, not hidden — the UI explains it"); + assert.equal(cands[cands.length - 1]!.branch, "feat/child"); + assert.equal(cands.filter((c) => c.isSelf).length, 1); + } finally { + s.cleanup(); + } +}); + +test("previews carry the diff each candidate would produce", async () => { + const s = await makeStack(); + try { + const cands = await targetCandidates(s.repo, s.child); + const parent = cands.find((c) => c.branch === "feat/parent")!; + const main = cands.find((c) => c.branch === "main")!; + // vs its parent: only the child's own line. + assert.equal(parent.insertions, 1, `vs parent, got ${parent.insertions}`); + // vs main: the parent's two lines come too — the inflated figure, correctly. + assert.equal(main.insertions, 3, `vs main, got ${main.insertions}`); + } finally { + s.cleanup(); + } +}); + +test("self is never previewed (it would measure nothing meaningful)", async () => { + const s = await makeStack(); + try { + const cands = await targetCandidates(s.repo, s.child); + const self = cands.find((c) => c.isSelf)!; + assert.equal(self.insertions, undefined); + assert.equal(self.deletions, undefined); + } finally { + s.cleanup(); + } +}); + +test("previews are capped, so opening the picker cannot storm git", async () => { + const s = await makeStack(); + try { + const g = (...args: string[]) => execFileSync("git", args, { cwd: s.repo }); + for (let i = 0; i < 12; i++) g("branch", `bulk/${i}`, "main"); + const cands = await targetCandidates(s.repo, s.child); + assert.equal(cands.length, 16, `expected every local branch, got ${cands.length}`); + const previewed = cands.filter((c) => c.insertions !== undefined); + assert.equal( + previewed.length <= PREVIEW_LIMIT, + true, + `previewed ${previewed.length}, cap is ${PREVIEW_LIMIT}`, + ); + // The cap must fall on the RANKED head, not an arbitrary slice. + assert.equal(previewed[0]!.branch, "feat/parent"); + assert.equal(previewed[1]!.branch, "main"); + } finally { + s.cleanup(); + } +}); + +test("local-only branches are marked so the UI can refuse them as a PR base", async () => { + const origin = mkdtempSync(join(tmpdir(), "makit-cand-origin-")); + const clone = mkdtempSync(join(tmpdir(), "makit-cand-clone-")); + const base = mkdtempSync(join(tmpdir(), "makit-cand-cwt-")); + try { + const g = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd }); + g(origin, "init", "-q", "-b", "main"); + g(origin, "config", "user.email", "t@t.io"); + g(origin, "config", "user.name", "Test"); + writeFileSync(join(origin, "R.md"), "x\n"); + g(origin, "add", "."); + g(origin, "commit", "-q", "-m", "init"); + execFileSync("git", ["clone", "-q", origin, clone]); + g(clone, "config", "user.email", "t@t.io"); + g(clone, "config", "user.name", "Test"); + g(clone, "branch", "never-pushed", "main"); + + const wt = await addWorktree({ + repoPath: clone, + name: "w", + branch: "feat/w", + startPoint: "main", + baseDir: base, + }); + const cands = await targetCandidates(clone, wt); + assert.equal(cands.find((c) => c.branch === "main")!.onRemote, true); + assert.equal(cands.find((c) => c.branch === "never-pushed")!.onRemote, false); + } finally { + rmSync(origin, { recursive: true, force: true }); + rmSync(clone, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } +}); + +test("a non-repo yields no candidates rather than throwing", async () => { + const plain = mkdtempSync(join(tmpdir(), "makit-cand-plain-")); + try { + assert.deepEqual(await targetCandidates(plain, plain), []); + } finally { + rmSync(plain, { recursive: true, force: true }); + } +}); + +/** + * Found by driving the real app: in a repo created with `git init` and no + * `origin`, every candidate came back `onRemote: false`, so the picker rendered + * every row disabled with "not pushed yet" and could not be used at all. + * + * The "must exist on the remote" rule exists because a PULL REQUEST base must. + * With no remote there is no pull request to constrain, and the target still + * drives the diff and the merge destination — so the constraint is vacuous and + * must not be enforced. + */ +test("with no remote configured, every branch is selectable", async () => { + const s = await makeStack(); + try { + const cands = await targetCandidates(s.repo, s.child); + assert.equal(cands.length > 0, true); + const blocked = cands.filter((c) => !c.isSelf && !c.onRemote); + assert.deepEqual( + blocked.map((c) => c.branch), + [], + "a repo with no remote must not gate on push state", + ); + } finally { + s.cleanup(); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// resolveThroughChain — rule 3's recursion, plus rule 4's fallback. +// ───────────────────────────────────────────────────────────────────────────── + +test("a repo whose only remote is `upstream` still offers selectable candidates", async () => { + // Regression: the push-state gate is origin-scoped (as `listRemoteBranchNames` + // is). An "any remote" gate switched ON here while the origin branch set came + // back EMPTY, so every candidate read "not pushed yet" and the picker was + // unusable in a fork-style checkout. + const s = await makeStack(); + try { + execFileSync("git", ["remote", "add", "upstream", "https://example.test/x/y.git"], { + cwd: s.repo, + }); + const cands = await targetCandidates(s.repo, s.child); + assert.equal(cands.length > 0, true); + const blocked = cands.filter((c) => !c.isSelf && !c.onRemote); + assert.deepEqual( + blocked.map((c) => c.branch), + [], + "no origin means the PR-base rule is vacuous, so nothing is gated", + ); + } finally { + s.cleanup(); + } +}); + +test("resolveThroughChain returns a live branch unchanged", () => { + assert.equal( + resolveThroughChain("feat/parent", { + live: new Set(["feat/parent", "main"]), + branchTarget: {}, + defaultBranch: "main", + }), + "feat/parent", + ); +}); + +test("resolveThroughChain follows one dead link to where it landed", () => { + // feat/child -> feat/parent (gone, landed in main) + assert.equal( + resolveThroughChain("feat/parent", { + live: new Set(["main"]), + branchTarget: { "feat/parent": "main" }, + defaultBranch: "main", + }), + "main", + ); +}); + +test("resolveThroughChain walks a multi-link chain", () => { + // A -> B -> C -> release/1.4, where B and C are both gone. The point of the + // recursion: the answer is where the chain ENDS, not the repo default. + assert.equal( + resolveThroughChain("B", { + live: new Set(["release/1.4", "main"]), + branchTarget: { B: "C", C: "release/1.4" }, + defaultBranch: "main", + }), + "release/1.4", + ); +}); + +test("resolveThroughChain falls back to the default when the chain dead-ends", () => { + assert.equal( + resolveThroughChain("B", { + live: new Set(["main"]), + branchTarget: { B: "C" }, + defaultBranch: "main", + }), + "main", + ); +}); + +test("resolveThroughChain survives a cycle instead of looping forever", () => { + // Reachable: A lands in B while B lands in A, then both branches vanish. + assert.equal( + resolveThroughChain("A", { + live: new Set(["main"]), + branchTarget: { A: "B", B: "A" }, + defaultBranch: "main", + }), + "main", + ); +}); + +test("resolveThroughChain returns null when even the default is gone", () => { + assert.equal( + resolveThroughChain("B", { + live: new Set(), + branchTarget: {}, + defaultBranch: null, + }), + null, + ); +}); + +test("resolveThroughChain never returns the branch it started from", () => { + // A self-referencing entry must not resolve to itself: that would leave a + // worktree targeting a branch that does not exist. + assert.equal( + resolveThroughChain("A", { + live: new Set(["main"]), + branchTarget: { A: "A" }, + defaultBranch: "main", + }), + "main", + ); +}); + +test("resolveThroughChain refuses a default branch that does not exist either", () => { + // A repo whose origin/HEAD still names a deleted branch: handing that back + // would move the "target is gone" problem one branch over rather than fix it. + assert.equal( + resolveThroughChain("B", { + live: new Set(["some-other"]), + branchTarget: {}, + defaultBranch: "main", + }), + null, + ); +}); + +test("a remote-only default branch is still offered, and grouped as the default", async () => { + // `resolveDefaultBranch` returns a remote-only override QUALIFIED (`origin/x`), + // because git cannot resolve a bare name against `refs/remotes/origin/`. Building + // the candidate list from local branches alone dropped it, so the picker could + // not offer the very branch every diff and new worktree measures against. + const s = await makeStack(); + try { + const sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: s.repo }).toString().trim(); + execFileSync("git", ["remote", "add", "origin", "https://example.test/x/y.git"], { + cwd: s.repo, + }); + execFileSync("git", ["update-ref", "refs/remotes/origin/release", sha], { cwd: s.repo }); + const cands = await targetCandidates(s.repo, s.child, "release"); + const def = cands.find((c) => c.group === "default"); + assert.equal(def?.branch, "origin/release", "the remote-only default is offered"); + assert.equal(def?.onRemote, true, "it is on the remote by definition, so selectable"); + assert.equal(def?.isSelf, false); + } finally { + s.cleanup(); + } +}); diff --git a/server/src/target_candidates.ts b/server/src/target_candidates.ts new file mode 100644 index 00000000..d363de79 --- /dev/null +++ b/server/src/target_candidates.ts @@ -0,0 +1,221 @@ +/** + * Ranked target-branch candidates for the picker. + * + * A flat alphabetical branch list is useless in a repo with forty branches: the + * answer is almost always one of four things, so candidates are grouped by *why* + * they are a candidate and each of the top few carries a preview of the diff it + * would produce. + * + * Lives apart from `repo_service` because it is per-request (a picker opening), + * not per-snapshot: it must never be dragged into the broadcast fan-out, where N + * candidates x M worktrees would storm git. + */ + +import { mapLimit } from "./concurrency.js"; +import { + closestAncestorBranch, + hasOriginRemote, + resolveDefaultBranch, + diffStat, + listLocalBranches, + listRemoteBranchNames, + listWorktrees, +} from "./git.js"; + +/** Why a branch is being offered. Drives the picker's section headers. */ +export type TargetCandidateGroup = "forkedFrom" | "default" | "worktree" | "other"; + +export interface TargetCandidate { + branch: string; + group: TargetCandidateGroup; + /** + * Whether the branch exists on a remote. A pull-request base must, so a + * local-only branch is listed **disabled with the reason** rather than accepted + * and then rejected by `gh` later. + */ + onRemote: boolean; + /** True for the worktree's own branch: offered, disabled, "this worktree". */ + isSelf: boolean; + /** What the diff would become. Absent when not previewed (see PREVIEW_LIMIT). */ + insertions?: number; + deletions?: number; +} + +/** + * How many of the ranked candidates get a diff preview. + * + * Each preview is one `git diff --numstat ...HEAD` — the same call the + * snapshot already makes, but N of them at once the instant a picker opens. Only + * the ranked few are worth it; "All branches" renders names and fills previews + * on demand. + */ +export const PREVIEW_LIMIT = 4; + +/** Concurrency for the preview diffs, so opening the picker cannot storm git. */ +const PREVIEW_CONCURRENCY = 4; + +/** + * Where a worktree should land when the branch it was aiming at is gone. + * + * Follows the chain: if `start` is gone but we recorded where *it* was going, + * try that, and keep going — a stack three deep collapses to wherever the stack + * actually landed rather than to the repo default, which would silently move the + * work to a different destination. + * + * Guards against a cycle (A lands in B while B lands in A, then both vanish) and + * against a self-reference, either of which would otherwise loop or resolve to a + * branch that does not exist. + * + * Returns the repo default when the chain dead-ends, and null when even that is + * missing — the only genuinely unanswerable case. + */ +export function resolveThroughChain( + start: string, + ctx: { + /** Branch names that currently exist. */ + live: ReadonlySet; + /** Branch -> the branch it lands in, for branches we still have a record of. */ + branchTarget: Readonly>; + defaultBranch: string | null; + }, +): string | null { + const { live, branchTarget, defaultBranch } = ctx; + const seen = new Set(); + let cur: string | undefined = start; + while (cur && !seen.has(cur)) { + if (live.has(cur)) return cur; + seen.add(cur); + cur = branchTarget[cur]; + } + // The default is only an answer if it actually exists. `detectDefaultBranch` + // normally guarantees that, but a repo mid-rename (or with origin/HEAD pointing + // at a deleted branch) can hand back a name that resolves to nothing, and + // returning it would just move the "target is gone" problem one branch over. + if (defaultBranch && live.has(defaultBranch)) return defaultBranch; + return null; +} + +/** + * Build the ranked candidate list for `worktreePath`. + * + * Order, and why: + * 1. **forkedFrom** — the closest ancestor branch. The honest default, and the + * one today's pill gets wrong. + * 2. **default** — the repo default; what you want the moment a stack lands. + * 3. **worktree** — branches checked out in other worktrees: the stacked case, + * and the only candidates whose target is a moving thing you can watch. + * 4. **other** — everything else, alphabetical, behind a filter in the UI. + * + * Each branch appears exactly once, in its highest-priority group. The worktree's + * own branch is included (flagged `isSelf`) rather than hidden, so the picker can + * explain why it is not selectable instead of leaving a confusing gap. + */ +export async function targetCandidates( + repoPath: string, + worktreePath: string, + /** + * The user's stored default-branch choice, threaded from the caller exactly as + * `repoSnapshot` does. Without it the picker would label and rank git's own + * answer as `default` while every diff and new worktree used the override — the + * picker disagreeing with the rest of the app about what "default" means. + */ + defaultBranchOverride?: string, +): Promise { + const [locals, onRemote, defaultBranch, trees, originExists] = await Promise.all([ + listLocalBranches(repoPath), + listRemoteBranchNames(repoPath), + resolveDefaultBranch(repoPath, defaultBranchOverride), + listWorktrees(repoPath), + hasOriginRemote(repoPath), + ]); + if (locals.length === 0) return []; + + const self = trees.find((t) => t.path === worktreePath)?.branch ?? null; + // The resolved default may be a remote-only branch, which `resolveDefaultBranch` + // returns QUALIFIED (`origin/release`) because git cannot resolve a bare name + // against `refs/remotes/origin/`. It is therefore absent from `locals`, and + // building the list from `locals` alone dropped it entirely — no candidate got + // the `default` group and the picker could not offer the very branch every diff + // and new worktree measures against. Offer it explicitly. + const remoteOnlyDefault = + defaultBranch && !locals.includes(defaultBranch) ? defaultBranch : null; + const selectable = remoteOnlyDefault ? [...locals, remoteOnlyDefault] : locals; + // Candidate order matters: `closestAncestorBranch` breaks distance ties by it, + // and the repo default is the tie we most want to win. + const ordered = [ + ...(defaultBranch && selectable.includes(defaultBranch) ? [defaultBranch] : []), + ...locals.filter((b) => b !== defaultBranch), + ]; + const forkedFrom = await closestAncestorBranch(worktreePath, ordered); + + const otherWorktreeBranches = new Set( + trees + .filter((t) => t.path !== worktreePath && t.branch) + .map((t) => t.branch as string), + ); + + const groupOf = (branch: string): TargetCandidateGroup => { + if (branch === forkedFrom) return "forkedFrom"; + if (branch === defaultBranch) return "default"; + if (otherWorktreeBranches.has(branch)) return "worktree"; + return "other"; + }; + + const rank: Record = { + forkedFrom: 0, + default: 1, + worktree: 2, + other: 3, + }; + + const candidates: TargetCandidate[] = selectable.map((branch) => ({ + branch, + group: groupOf(branch), + // The push-state gate exists because a PULL REQUEST base must live on the + // remote. With no `origin` there is no pull request for it to constrain, while + // the target still drives the diff and the merge destination — so the rule is + // vacuous and enforcing it would disable every row and leave the picker + // unusable. (Found by driving the real app against a plain `git init` repo: + // every candidate read "not pushed yet".) + // + // Gated on `origin` specifically, matching `listRemoteBranchNames`' scope: a + // repo whose only remote is `upstream` has a remote but no origin branches, so + // an "any remote" gate would switch the rule ON against an EMPTY set and + // disable every candidate. + // A remote-only default is `origin/`, which is by definition on the remote + // but never in the stripped `onRemote` set — treat it as pushed. + onRemote: originExists ? branch === remoteOnlyDefault || onRemote.has(branch) : true, + isSelf: branch === self, + })); + + candidates.sort((a, b) => { + const byGroup = rank[a.group] - rank[b.group]; + if (byGroup !== 0) return byGroup; + // Self last within its group: it is present only to be explained. + if (a.isSelf !== b.isSelf) return a.isSelf ? 1 : -1; + return a.branch.localeCompare(b.branch); + }); + + // Preview only the leading SELECTABLE candidates: not self, and on the remote + // (an off-remote candidate is offered but disabled, so it must not consume a + // preview slot a selectable remote-backed candidate could have used). In a + // repo with no remote every candidate is `onRemote: true` (the vacuous rule + // above), so this does not starve previews there. + const previewable = candidates + .filter((c) => !c.isSelf && c.onRemote) + .slice(0, PREVIEW_LIMIT); + const stats = await mapLimit(previewable, PREVIEW_CONCURRENCY, (c) => + diffStat(worktreePath, c.branch), + ); + previewable.forEach((c, i) => { + const s = stats[i]; + // A preview that could not be measured is omitted rather than shown as zero — + // the same reason `DiffStat.targetResolved` exists. + if (s && s.targetResolved) { + c.insertions = s.insertions; + c.deletions = s.deletions; + } + }); + + return candidates; +} diff --git a/server/src/target_rules.test.ts b/server/src/target_rules.test.ts new file mode 100644 index 00000000..8f40d9c0 --- /dev/null +++ b/server/src/target_rules.test.ts @@ -0,0 +1,569 @@ +/** + * Rules 2, 3 and 4 of the target-branch contract, against real git. + * + * 2. Renaming a branch must follow through to every worktree that lands in it. + * 3. Wrapping up a branch hands its own target down to whoever was landing in + * it — recursively, because the branch we hand them may already be gone. + * 4. A target that vanishes WITHOUT a wrap-up (a manual `git branch -D`, or a + * forge auto-deleting a merged head) falls back to the repo default, and the + * change is recorded so it can be announced rather than done silently. + * + * These go through the manager rather than the store so the wiring is covered, + * not just the helpers. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { SessionManager } from "./manager.js"; +import { loadTargets, worktreeTargetsFile, putTarget } from "./worktree-target-store.js"; + +interface Fixture { + repo: string; + manager: SessionManager; + projectId: string; + cleanup: () => void; +} + +/** A repo with `main`, plus however many worktrees the caller asks for. */ +async function fixture(): Promise { + const home = mkdtempSync(join(tmpdir(), "makit-rules-home-")); + const repo = mkdtempSync(join(tmpdir(), "makit-rules-repo-")); + const wtDir = mkdtempSync(join(tmpdir(), "makit-rules-wt-")); + const prevHome = process.env.MAKIT_HOME; + const prevWt = process.env.MAKIT_WORKTREE_DIR; + process.env.MAKIT_HOME = home; + process.env.MAKIT_WORKTREE_DIR = wtDir; + // Pin the targets file explicitly. `worktreeTargetsFile()` prefers + // MAKIT_WORKTREE_TARGETS_FILE over MAKIT_HOME, so an inherited value from + // another suite would silently point these tests at a shared store. + const prevTargets = process.env.MAKIT_WORKTREE_TARGETS_FILE; + process.env.MAKIT_WORKTREE_TARGETS_FILE = join(home, "worktree-targets.json"); + + const g = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd }); + g(repo, "init", "-q", "-b", "main"); + g(repo, "config", "user.email", "t@t.io"); + g(repo, "config", "user.name", "Test"); + writeFileSync(join(repo, "README.md"), "base\n"); + g(repo, "add", "."); + g(repo, "commit", "-q", "-m", "initial"); + + const manager = new SessionManager({ projects: [repo] }); + const projectId = manager.listProjects()[0]!.id; + return { + repo, + manager, + projectId, + cleanup: () => { + if (prevHome === undefined) delete process.env.MAKIT_HOME; + else process.env.MAKIT_HOME = prevHome; + if (prevWt === undefined) delete process.env.MAKIT_WORKTREE_DIR; + else process.env.MAKIT_WORKTREE_DIR = prevWt; + if (prevTargets === undefined) delete process.env.MAKIT_WORKTREE_TARGETS_FILE; + else process.env.MAKIT_WORKTREE_TARGETS_FILE = prevTargets; + rmSync(home, { recursive: true, force: true }); + rmSync(repo, { recursive: true, force: true }); + rmSync(wtDir, { recursive: true, force: true }); + }, + }; +} + +/** Create a worktree via the manager and give it a commit. */ +async function branchWorktree( + f: Fixture, + branchName: string, + target: string, +): Promise { + const { path } = await f.manager.createWorktree(f.projectId, target, branchName); + writeFileSync(join(path, `${branchName.replace(/\//g, "-")}.txt`), "x\n"); + execFileSync("git", ["add", "."], { cwd: path }); + execFileSync("git", ["commit", "-q", "-m", branchName], { cwd: path }); + return path; +} + +// ── rule 2 ─────────────────────────────────────────────────────────────────── + +test("rule 2: renaming a branch repoints every worktree that lands in it", async () => { + const f = await fixture(); + try { + const parent = await branchWorktree(f, "parent", "main"); + const parentBranch = (await f.manager.listRepos({ includePrs: false }))[0]!.worktrees.find( + (w) => w.path === parent, + )!.branch!; + const childA = await branchWorktree(f, "child-a", parentBranch); + const childB = await branchWorktree(f, "child-b", parentBranch); + + await f.manager.renameWorktreeBranch(f.projectId, parent, "parent-renamed"); + + const all = loadTargets(worktreeTargetsFile()); + assert.equal(all[childA]?.target, "parent-renamed"); + assert.equal(all[childB]?.target, "parent-renamed"); + // The rename is not an automatic retarget — nothing to announce. + assert.equal(all[childA]?.retargetedFrom, undefined); + } finally { + f.cleanup(); + } +}); + +test("rule 2: an unrelated worktree's target is untouched by a rename", async () => { + const f = await fixture(); + try { + const parent = await branchWorktree(f, "parent", "main"); + const other = await branchWorktree(f, "other", "main"); + await f.manager.renameWorktreeBranch(f.projectId, parent, "parent-renamed"); + assert.equal(loadTargets(worktreeTargetsFile())[other]?.target, "main"); + } finally { + f.cleanup(); + } +}); + +// ── rule 4 ─────────────────────────────────────────────────────────────────── + +test("rule 4: a target deleted outside makit falls back to the default and says so", async () => { + const f = await fixture(); + try { + const parent = await branchWorktree(f, "parent", "main"); + const repos1 = await f.manager.listRepos({ includePrs: false }); + const parentBranch = repos1[0]!.worktrees.find((w) => w.path === parent)!.branch!; + const child = await branchWorktree(f, "child", parentBranch); + + // Someone tidies the parent up by hand: no wrap-up, so nothing was handed down. + execFileSync("git", ["worktree", "remove", "--force", parent], { cwd: f.repo }); + execFileSync("git", ["branch", "-D", parentBranch], { cwd: f.repo }); + + const repos = await f.manager.listRepos({ includePrs: false }); + const w = repos[0]!.worktrees.find((x) => x.path === child)!; + assert.equal(w.targetBranch, "main", "must fall back rather than dangle"); + assert.equal(w.targetResolved, true, "and the fallback must actually resolve"); + assert.equal( + w.retargetedFrom, + parentBranch, + "the automatic change must be announceable, not silent", + ); + } finally { + f.cleanup(); + } +}); + +test("rule 4: the repair is persisted, not recomputed on every snapshot", async () => { + const f = await fixture(); + try { + const parent = await branchWorktree(f, "parent", "main"); + const repos1 = await f.manager.listRepos({ includePrs: false }); + const parentBranch = repos1[0]!.worktrees.find((w) => w.path === parent)!.branch!; + const child = await branchWorktree(f, "child", parentBranch); + execFileSync("git", ["worktree", "remove", "--force", parent], { cwd: f.repo }); + execFileSync("git", ["branch", "-D", parentBranch], { cwd: f.repo }); + + await f.manager.listRepos({ includePrs: false }); + const stored = loadTargets(worktreeTargetsFile())[child]; + assert.equal(stored?.target, "main"); + assert.equal(stored?.retargetedFrom, parentBranch); + } finally { + f.cleanup(); + } +}); + +test("rule 4: an explicit choice clears the announcement", async () => { + const f = await fixture(); + try { + const parent = await branchWorktree(f, "parent", "main"); + const repos1 = await f.manager.listRepos({ includePrs: false }); + const parentBranch = repos1[0]!.worktrees.find((w) => w.path === parent)!.branch!; + const child = await branchWorktree(f, "child", parentBranch); + execFileSync("git", ["worktree", "remove", "--force", parent], { cwd: f.repo }); + execFileSync("git", ["branch", "-D", parentBranch], { cwd: f.repo }); + await f.manager.listRepos({ includePrs: false }); + + // The user takes ownership of the value; there is nothing left to tell them. + await f.manager.setWorktreeTarget(f.projectId, child, "main"); + assert.equal(loadTargets(worktreeTargetsFile())[child]?.retargetedFrom, undefined); + + const repos = await f.manager.listRepos({ includePrs: false }); + assert.equal(repos[0]!.worktrees.find((x) => x.path === child)!.retargetedFrom, null); + } finally { + f.cleanup(); + } +}); + +test("a healthy repo is never repaired, so the store is not churned", async () => { + const f = await fixture(); + try { + const parent = await branchWorktree(f, "parent", "main"); + const repos1 = await f.manager.listRepos({ includePrs: false }); + const parentBranch = repos1[0]!.worktrees.find((w) => w.path === parent)!.branch!; + const child = await branchWorktree(f, "child", parentBranch); + await f.manager.listRepos({ includePrs: false }); + const stored = loadTargets(worktreeTargetsFile())[child]; + assert.equal(stored?.target, parentBranch, "an intact target must be left alone"); + assert.equal(stored?.retargetedFrom, undefined); + } finally { + f.cleanup(); + } +}); + +// ── rule 3 ─────────────────────────────────────────────────────────────────── + +test("rule 3: wrapping up a branch hands its target down to whoever landed in it", async () => { + const f = await fixture(); + try { + const parent = await branchWorktree(f, "parent", "main"); + const repos1 = await f.manager.listRepos({ includePrs: false }); + const parentBranch = repos1[0]!.worktrees.find((w) => w.path === parent)!.branch!; + const child = await branchWorktree(f, "child", parentBranch); + + // The parent lands. makit does the tidying, so it knows where it went. + await f.manager.wrapUpWorktree(f.projectId, parent, "main"); + + const stored = loadTargets(worktreeTargetsFile())[child]; + assert.equal(stored?.target, "main", "the child follows the parent to where it landed"); + assert.equal( + stored?.retargetedFrom, + parentBranch, + "and the hand-down is announced, not silent", + ); + } finally { + f.cleanup(); + } +}); + +test("rule 3: the hand-down follows a chain when the middle link is already gone", async () => { + const f = await fixture(); + try { + // grand -> mid -> leaf, all landing on the one below. `mid` is wrapped up + // first, then `grand`; by the time `grand` is wrapped the branch it names is + // already gone, so a single hop would dead-end and fall back to the default. + const grand = await branchWorktree(f, "grand", "main"); + let repos = await f.manager.listRepos({ includePrs: false }); + const grandBranch = repos[0]!.worktrees.find((w) => w.path === grand)!.branch!; + + const mid = await branchWorktree(f, "mid", grandBranch); + repos = await f.manager.listRepos({ includePrs: false }); + const midBranch = repos[0]!.worktrees.find((w) => w.path === mid)!.branch!; + + const leaf = await branchWorktree(f, "leaf", midBranch); + + // `mid` lands in `grand`. The leaf now aims at `grand`. + await f.manager.wrapUpWorktree(f.projectId, mid, grandBranch); + assert.equal(loadTargets(worktreeTargetsFile())[leaf]?.target, grandBranch); + + // Now `grand` lands in main. The leaf must follow to main. + await f.manager.wrapUpWorktree(f.projectId, grand, "main"); + assert.equal( + loadTargets(worktreeTargetsFile())[leaf]?.target, + "main", + "the chain must collapse to where the stack actually landed", + ); + } finally { + f.cleanup(); + } +}); + +test("rule 3: hands children down to a remote-only landing branch (offline fetch)", async () => { + const f = await fixture(); + try { + // `release` exists only as a remote-tracking ref — as if a prior fetch saw it + // but the wrap-up's own fetch cannot land it locally (offline / transient). + // It must still count as a live landing branch, or every child is dragged to + // the repo default instead of the branch the PR actually targeted. + const sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: f.repo }) + .toString() + .trim(); + execFileSync("git", ["update-ref", "refs/remotes/origin/release", sha], { cwd: f.repo }); + + const parent = await branchWorktree(f, "parent", "main"); + const repos1 = await f.manager.listRepos({ includePrs: false }); + const parentBranch = repos1[0]!.worktrees.find((w) => w.path === parent)!.branch!; + const child = await branchWorktree(f, "child", parentBranch); + + // The parent lands in the remote-only `release`. + await f.manager.wrapUpWorktree(f.projectId, parent, "release"); + + const stored = loadTargets(worktreeTargetsFile())[child]; + assert.equal( + stored?.target, + "release", + "a remote-only landing branch is live, so the child follows it — not the default", + ); + assert.equal(stored?.retargetedFrom, parentBranch); + } finally { + f.cleanup(); + } +}); + +test("rule 3: a worktree that landed elsewhere is not dragged along", async () => { + const f = await fixture(); + try { + const parent = await branchWorktree(f, "parent", "main"); + const repos1 = await f.manager.listRepos({ includePrs: false }); + const parentBranch = repos1[0]!.worktrees.find((w) => w.path === parent)!.branch!; + const child = await branchWorktree(f, "child", parentBranch); + const bystander = await branchWorktree(f, "bystander", "main"); + + await f.manager.wrapUpWorktree(f.projectId, parent, "main"); + + const all = loadTargets(worktreeTargetsFile()); + assert.equal(all[child]?.retargetedFrom, parentBranch, "the child moved"); + assert.equal( + all[bystander]?.retargetedFrom, + undefined, + "the bystander already landed in main and must not be marked as moved", + ); + } finally { + f.cleanup(); + } +}); + +// ── B7: pull-request lifecycle ─────────────────────────────────────────────── +// +// The design has two backing stores for one value: a live PR's base wins, the +// persisted choice applies otherwise. The TRANSITIONS between those were +// unspecified, and each one is a window where the displayed target is wrong: +// +// * PR created with a base we did not choose (`gh pr create --base` by hand) — +// the persisted value must catch up, or closing the PR later reverts to a +// value that was never true. +// * PR closed / reopened — the fallback must be where the PR actually pointed, +// not a stale pre-PR value. +// * GitHub auto-retargets a stacked PR and then auto-closes it — without a +// write-back we would fall back to a target that no longer matches reality. +// +// The fix is convergence: whenever a live PR's base wins, persist it. + +/** + * A `LastKnownPr` that reports one fake pull request for the worktree at `path`. + * + * Stands in for the previous broadcast's enrichment, which is exactly what the + * adoption step reads — so these tests exercise the real code path rather than a + * shortcut. + */ +function withPr( + f: Fixture, + path: string, + pr: { state: string; baseRefName: string }, +) { + return (repoPath: string, branch: string) => + repoPath === f.repo && branchCache[path] === branch + ? ({ + number: 1, + url: "u", + state: pr.state, + title: "t", + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + checks: [], + checkRollup: "none", + unresolvedComments: 0, + baseRefName: pr.baseRefName, + } as never) + : null; +} + +/** Worktree path -> its branch, filled in by each test after creation. */ +let branchCache: Record = {}; + +test("B7: a live PR's base is adopted into the persisted value", async () => { + const f = await fixture(); + branchCache = {}; + try { + const parent = await branchWorktree(f, "parent", "main"); + let repos = await f.manager.listRepos({ includePrs: false }); + const parentBranch = repos[0]!.worktrees.find((w) => w.path === parent)!.branch!; + const child = await branchWorktree(f, "child", parentBranch); + repos = await f.manager.listRepos({ includePrs: false }); + branchCache[child] = repos[0]!.worktrees.find((w) => w.path === child)!.branch!; + + // The user opened the PR against main by hand, not against the parent. + await f.manager.listRepos( + { includePrs: false }, + withPr(f, child, { state: "OPEN", baseRefName: "main" }), + ); + + const stored = loadTargets(worktreeTargetsFile())[child]; + assert.equal(stored?.target, "main", "the persisted value must catch up to the PR"); + assert.equal( + stored?.retargetedFrom, + parentBranch, + "and the change is announced, since it overrode a value we had", + ); + } finally { + branchCache = {}; + f.cleanup(); + } +}); + +test("B7: closing the PR keeps the adopted target instead of reverting", async () => { + const f = await fixture(); + branchCache = {}; + try { + const parent = await branchWorktree(f, "parent", "main"); + let repos = await f.manager.listRepos({ includePrs: false }); + const parentBranch = repos[0]!.worktrees.find((w) => w.path === parent)!.branch!; + const child = await branchWorktree(f, "child", parentBranch); + repos = await f.manager.listRepos({ includePrs: false }); + branchCache[child] = repos[0]!.worktrees.find((w) => w.path === child)!.branch!; + + // Live PR against main -> adopted. + await f.manager.listRepos( + { includePrs: false }, + withPr(f, child, { state: "OPEN", baseRefName: "main" }), + ); + // Now it closes. Before the write-back this fell back to `parentBranch` — a + // value that had not been true since the PR was opened. + const after = await f.manager.listRepos( + { includePrs: false }, + withPr(f, child, { state: "CLOSED", baseRefName: "main" }), + ); + assert.equal( + after[0]!.worktrees.find((w) => w.path === child)!.targetBranch, + "main", + "the fallback must be where the PR actually pointed", + ); + } finally { + branchCache = {}; + f.cleanup(); + } +}); + +test("B7: a merged PR stops overriding the user's own choice", async () => { + const f = await fixture(); + branchCache = {}; + try { + const parent = await branchWorktree(f, "parent", "main"); + let repos = await f.manager.listRepos({ includePrs: false }); + const parentBranch = repos[0]!.worktrees.find((w) => w.path === parent)!.branch!; + const child = await branchWorktree(f, "child", parentBranch); + repos = await f.manager.listRepos({ includePrs: false }); + branchCache[child] = repos[0]!.worktrees.find((w) => w.path === child)!.branch!; + + // The user deliberately points at the parent while a MERGED PR names main. + await f.manager.setWorktreeTarget(f.projectId, child, parentBranch); + const after = await f.manager.listRepos( + { includePrs: false }, + withPr(f, child, { state: "MERGED", baseRefName: "main" }), + ); + assert.equal( + after[0]!.worktrees.find((w) => w.path === child)!.targetBranch, + parentBranch, + "history must not outrank a live choice", + ); + assert.equal(loadTargets(worktreeTargetsFile())[child]?.target, parentBranch); + } finally { + branchCache = {}; + f.cleanup(); + } +}); + +test("B7: adopting a base that already matches announces nothing", async () => { + const f = await fixture(); + branchCache = {}; + try { + const child = await branchWorktree(f, "child", "main"); + const repos = await f.manager.listRepos({ includePrs: false }); + branchCache[child] = repos[0]!.worktrees.find((w) => w.path === child)!.branch!; + await f.manager.listRepos( + { includePrs: false }, + withPr(f, child, { state: "OPEN", baseRefName: "main" }), + ); + const stored = loadTargets(worktreeTargetsFile())[child]; + assert.equal(stored?.target, "main"); + assert.equal(stored?.retargetedFrom, undefined, "agreement is not news"); + } finally { + branchCache = {}; + f.cleanup(); + } +}); + +// Cross-repo isolation: the target store is GLOBAL across every project, and +// branch names are not unique across repos. A wrap-up or rename in one repo must +// never rewrite a same-named target belonging to another repo. + +test("rule 3: a wrap-up does not hand down a same-named target in another repo", async () => { + const f = await fixture(); + try { + const parent = await branchWorktree(f, "parent", "main"); + const repos1 = await f.manager.listRepos({ includePrs: false }); + const parentBranch = repos1[0]!.worktrees.find((w) => w.path === parent)!.branch!; + const child = await branchWorktree(f, "child", parentBranch); + + // A worktree in a DIFFERENT repo that happens to land in a branch with the + // same name as the one we are about to wrap up. + const foreign = "/some/other/repo/wt-foreign"; + putTarget(worktreeTargetsFile(), foreign, parentBranch); + + await f.manager.wrapUpWorktree(f.projectId, parent, "main"); + + const all = loadTargets(worktreeTargetsFile()); + assert.equal(all[child]?.target, "main", "our own child follows the wrap-up"); + assert.equal( + all[foreign]?.target, + parentBranch, + "the foreign repo's worktree must be left exactly as it was", + ); + assert.equal(all[foreign]?.retargetedFrom, undefined, "and not marked as moved"); + } finally { + f.cleanup(); + } +}); + +test("rule 2: a rename does not rewrite a same-named target in another repo", async () => { + const f = await fixture(); + try { + const parent = await branchWorktree(f, "parent", "main"); + const repos1 = await f.manager.listRepos({ includePrs: false }); + const parentBranch = repos1[0]!.worktrees.find((w) => w.path === parent)!.branch!; + const child = await branchWorktree(f, "child", parentBranch); + + // Another repo's worktree targeting a branch with the same name. + const foreign = "/some/other/repo/wt-foreign"; + putTarget(worktreeTargetsFile(), foreign, parentBranch); + + await f.manager.renameWorktreeBranch(f.projectId, parent, "parent-renamed"); + + const all = loadTargets(worktreeTargetsFile()); + assert.equal(all[child]?.target, "parent-renamed", "our own child follows the rename"); + assert.equal( + all[foreign]?.target, + parentBranch, + "the foreign repo's target with the same branch name is untouched", + ); + } finally { + f.cleanup(); + } +}); + +test("rule 4: a target that still exists on origin is NOT repaired away", async () => { + // The failure this guards: an open PR into a remote-only `release` is adopted, + // the PR closes, and the repair pass — seeing no LOCAL `release` — rewrites the + // worktree's target to the repo default, silently moving every future diff and + // PR base. A branch that exists on the remote has not vanished; the honest + // outcome is `targetResolved: false` until it is fetched, never a redirect. + const f = await fixture(); + try { + const wt = await branchWorktree(f, "child", "main"); + const sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: f.repo }).toString().trim(); + // `release` exists ONLY as a remote-tracking ref. + execFileSync("git", ["update-ref", "refs/remotes/origin/release", sha], { cwd: f.repo }); + putTarget(worktreeTargetsFile(), wt, "release"); + + await f.manager.listRepos({ includePrs: false }); + + assert.equal( + loadTargets(worktreeTargetsFile())[wt]?.target, + "release", + "a remote-only target must survive the repair pass", + ); + assert.equal( + loadTargets(worktreeTargetsFile())[wt]?.retargetedFrom, + undefined, + "and must not be announced as an automatic retarget", + ); + } finally { + f.cleanup(); + } +}); diff --git a/server/src/worktree-target-store.test.ts b/server/src/worktree-target-store.test.ts new file mode 100644 index 00000000..134baed9 --- /dev/null +++ b/server/src/worktree-target-store.test.ts @@ -0,0 +1,435 @@ +/** + * Tests for the per-worktree target-branch store. + * + * The interesting behaviour is not "can it round-trip a string" but the three + * hazards the design review surfaced (§0 B2, R9): + * * a torn write must never be observable (R9 atomicity), + * * a removed-and-recreated worktree must not inherit the dead one's target, + * because `addWorktree` derives its path deterministically from repo + name, + * * a corrupt or missing file must degrade to "no targets", never throw, so the + * server always starts. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + loadTargets, + saveTargets, + putTarget, + clearTarget, + pruneTargets, + targetOf, + renameTargetBranch, +} from "./worktree-target-store.js"; + +function tmpFile(): { dir: string; file: string } { + const dir = mkdtempSync(join(tmpdir(), "makit-targets-")); + return { dir, file: join(dir, "worktree-targets.json") }; +} + +test("loadTargets returns an empty map for a missing file", () => { + const { dir, file } = tmpFile(); + try { + assert.deepEqual(loadTargets(file), {}); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadTargets degrades to empty on a corrupt file rather than throwing", () => { + const { dir, file } = tmpFile(); + try { + writeFileSync(file, "{ this is not json"); + assert.deepEqual(loadTargets(file), {}); + // Wrong shape at the top level. + writeFileSync(file, JSON.stringify({ targets: "nope" })); + assert.deepEqual(loadTargets(file), {}); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadTargets drops legacy entries whose value is not a branch string", () => { + const { dir, file } = tmpFile(); + try { + writeFileSync( + file, + JSON.stringify({ targets: { "/a": "main", "/b": 7, "/c": null, "/d": "" } }), + ); + assert.deepEqual(loadTargets(file), { "/a": { target: "main" } }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("putTarget persists and is readable back", () => { + const { dir, file } = tmpFile(); + try { + putTarget(file, "/wt/a", "main"); + putTarget(file, "/wt/b", "feat/parent"); + assert.deepEqual(loadTargets(file), { + "/wt/a": { target: "main" }, + "/wt/b": { target: "feat/parent" }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("putTarget overwrites only its own key", () => { + const { dir, file } = tmpFile(); + try { + putTarget(file, "/wt/a", "main"); + putTarget(file, "/wt/b", "feat/parent"); + putTarget(file, "/wt/a", "release/1.4"); + assert.deepEqual(loadTargets(file), { + "/wt/a": { target: "release/1.4" }, + "/wt/b": { target: "feat/parent" }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("clearTarget removes one key and leaves the rest", () => { + const { dir, file } = tmpFile(); + try { + putTarget(file, "/wt/a", "main"); + putTarget(file, "/wt/b", "feat/parent"); + clearTarget(file, "/wt/a"); + assert.deepEqual(loadTargets(file), { "/wt/b": { target: "feat/parent" } }); + // Clearing something absent is a no-op, not an error. + clearTarget(file, "/wt/nope"); + assert.deepEqual(loadTargets(file), { "/wt/b": { target: "feat/parent" } }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +/** + * R9: the write must be atomic. We cannot easily induce a real crash mid-write, + * so we assert the mechanism instead: the file is replaced by rename, so no + * partial JSON is ever observable and no temp files are left behind. + */ +test("saveTargets writes atomically and leaves no temp files behind", () => { + const { dir, file } = tmpFile(); + try { + saveTargets(file, { "/wt/a": { target: "main" } }); + // Valid JSON after the write, and exactly one file in the directory. + assert.deepEqual(JSON.parse(readFileSync(file, "utf8")), { + targets: { "/wt/a": { target: "main" } }, + }); + assert.deepEqual(readdirSync(dir), ["worktree-targets.json"]); + // A second write also leaves the dir clean (no accumulating .tmp siblings). + saveTargets(file, { "/wt/a": { target: "release/1.4" } }); + assert.deepEqual(readdirSync(dir), ["worktree-targets.json"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("saveTargets creates the parent directory", () => { + const { dir } = tmpFile(); + try { + const nested = join(dir, "deep", "deeper", "worktree-targets.json"); + saveTargets(nested, { "/wt/a": { target: "main" } }); + assert.deepEqual(loadTargets(nested), { "/wt/a": { target: "main" } }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +/** + * B2's sharpest hazard: `addWorktree` builds its path as + * `//`, so removing a worktree and creating another + * with the same name yields the SAME path. Without a prune, the new worktree + * silently inherits the dead one's target. + */ +test("pruneTargets drops entries for worktrees that no longer exist", () => { + const { dir, file } = tmpFile(); + try { + saveTargets(file, { + "/wt/live": { target: "main" }, + "/wt/dead": { target: "feat/gone" }, + "/wt/also-live": { target: "release/1.4" }, + }); + const removed = pruneTargets(file, ["/wt/live", "/wt/also-live"]); + assert.equal(removed, 1); + assert.deepEqual(loadTargets(file), { + "/wt/live": { target: "main" }, + "/wt/also-live": { target: "release/1.4" }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("pruneTargets does not rewrite the file when nothing is stale", () => { + const { dir, file } = tmpFile(); + try { + saveTargets(file, { "/wt/live": { target: "main" } }); + const before = readFileSync(file, "utf8"); + const removed = pruneTargets(file, ["/wt/live"]); + assert.equal(removed, 0); + // Byte-identical: a no-op prune must not churn the file on every snapshot. + assert.equal(readFileSync(file, "utf8"), before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("pruneTargets with an empty live set clears everything", () => { + const { dir, file } = tmpFile(); + try { + saveTargets(file, { "/wt/a": { target: "main" }, "/wt/b": { target: "main" } }); + assert.equal(pruneTargets(file, []), 2); + assert.deepEqual(loadTargets(file), {}); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +/** + * R9 again, from the read-modify-write angle: `putTarget` loads, mutates and + * saves. Sequential calls must compose — last write wins per key, and no + * earlier key is lost. + */ +test("interleaved putTarget calls compose without losing keys", () => { + const { dir, file } = tmpFile(); + try { + for (let i = 0; i < 25; i++) putTarget(file, `/wt/${i}`, i % 2 ? "main" : "feat/parent"); + const all = loadTargets(file); + assert.equal(Object.keys(all).length, 25); + assert.equal(all["/wt/24"]?.target, "feat/parent"); + assert.equal(all["/wt/23"]?.target, "main"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Richer entries: a target plus "what it used to be", so an automatic repoint +// can be announced instead of happening behind the user's back. +// ───────────────────────────────────────────────────────────────────────────── + +test("an entry can carry what the target used to be", () => { + const { dir, file } = tmpFile(); + try { + putTarget(file, "/wt/a", "main", { retargetedFrom: "feat/parent" }); + assert.deepEqual(loadTargets(file), { + "/wt/a": { target: "main", retargetedFrom: "feat/parent" }, + }); + assert.equal(targetOf(file, "/wt/a"), "main"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("putTarget without a note clears any previous note", () => { + const { dir, file } = tmpFile(); + try { + putTarget(file, "/wt/a", "main", { retargetedFrom: "feat/parent" }); + // An explicit choice means the user has taken ownership: there is nothing + // left to announce, so the note must not linger. + putTarget(file, "/wt/a", "release/1.4"); + assert.deepEqual(loadTargets(file), { "/wt/a": { target: "release/1.4" } }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadTargets still reads the legacy bare-string shape", () => { + const { dir, file } = tmpFile(); + try { + // The first version of this file stored `path -> "branch"`. Upgrading must + // not silently drop everyone's targets. + writeFileSync(file, JSON.stringify({ targets: { "/wt/a": "main" } })); + assert.deepEqual(loadTargets(file), { "/wt/a": { target: "main" } }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadTargets drops an entry whose target is not a usable string", () => { + const { dir, file } = tmpFile(); + try { + writeFileSync( + file, + JSON.stringify({ + targets: { + "/ok": { target: "main" }, + "/empty": { target: "" }, + "/num": { target: 7 }, + "/missing": { retargetedFrom: "x" }, + }, + }), + ); + assert.deepEqual(loadTargets(file), { "/ok": { target: "main" } }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── rule 2: a branch rename must follow through ────────────────────────────── + +test("renameTargetBranch repoints every worktree that landed in the old name", () => { + const { dir, file } = tmpFile(); + try { + putTarget(file, "/wt/a", "feat/parent"); + putTarget(file, "/wt/b", "feat/parent"); + putTarget(file, "/wt/c", "main"); + const moved = renameTargetBranch(file, "feat/parent", "feat/renamed"); + assert.equal(moved, 2); + assert.equal(targetOf(file, "/wt/a"), "feat/renamed"); + assert.equal(targetOf(file, "/wt/b"), "feat/renamed"); + assert.equal(targetOf(file, "/wt/c"), "main", "unrelated targets are untouched"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("renameTargetBranch is a silent no-op when nothing points at the old name", () => { + const { dir, file } = tmpFile(); + try { + putTarget(file, "/wt/a", "main"); + const before = readFileSync(file, "utf8"); + assert.equal(renameTargetBranch(file, "feat/nope", "feat/other"), 0); + assert.equal(readFileSync(file, "utf8"), before, "must not churn the file"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("renameTargetBranch preserves an existing note", () => { + const { dir, file } = tmpFile(); + try { + putTarget(file, "/wt/a", "feat/parent", { retargetedFrom: "old" }); + renameTargetBranch(file, "feat/parent", "feat/new"); + assert.deepEqual(loadTargets(file)["/wt/a"], { + target: "feat/new", + retargetedFrom: "old", + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("saveTargets/putTarget return false when the write cannot land", () => { + // Point the store under a path whose parent is a regular FILE, so the + // atomic-write's `mkdirSync` fails with ENOTDIR — a deterministic write + // failure. The interactive command relies on this signal to avoid acking a + // success the disk refused. + const { dir } = tmpFile(); + try { + const blocker = join(dir, "blocker"); + writeFileSync(blocker, "not a directory"); + const file = join(blocker, "sub", "worktree-targets.json"); + assert.equal(saveTargets(file, { "/wt": { target: "main" } }), false); + assert.equal(putTarget(file, "/wt", "main"), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("saveTargets/putTarget return true on a successful write", () => { + const { dir, file } = tmpFile(); + try { + assert.equal(saveTargets(file, { "/wt": { target: "main" } }), true); + assert.equal(putTarget(file, "/wt2", "dev"), true); + assert.equal(targetOf(file, "/wt2"), "dev"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// putTarget's compare-and-set. Background reconciliation (adopt / repair / +// hand-down) decides what to write from a map read BEFORE its own async git +// reads, so a user's `worktree.setTarget` can land in that window. The user's +// explicit choice must win over the stale automatic one. + +test("putTarget with `expect` refuses the write when the target moved under it", () => { + const { dir, file } = tmpFile(); + try { + putTarget(file, "/wt", "feat/parent"); + // The user retargets while a reconciliation is mid-flight. + putTarget(file, "/wt", "main"); + // The reconciliation now tries to write a decision based on the OLD value. + const ok = putTarget(file, "/wt", "release/1.4", { expect: "feat/parent" }); + assert.equal(ok, false, "a stale decision must not be applied"); + assert.equal(targetOf(file, "/wt"), "main", "the user's choice survives"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("putTarget with a matching `expect` applies the write", () => { + const { dir, file } = tmpFile(); + try { + putTarget(file, "/wt", "feat/parent"); + assert.equal(putTarget(file, "/wt", "main", { expect: "feat/parent" }), true); + assert.equal(targetOf(file, "/wt"), "main"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("putTarget with `expect: null` means `there must be no entry yet`", () => { + const { dir, file } = tmpFile(); + try { + assert.equal(putTarget(file, "/wt", "main", { expect: null }), true, "absent as expected"); + assert.equal( + putTarget(file, "/wt", "other", { expect: null }), + false, + "an entry appeared, so the decision is stale", + ); + assert.equal(targetOf(file, "/wt"), "main"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("putTarget without `expect` is an unconditional write (interactive path)", () => { + const { dir, file } = tmpFile(); + try { + putTarget(file, "/wt", "feat/parent"); + assert.equal(putTarget(file, "/wt", "main"), true, "setWorktreeTarget must not be gated"); + assert.equal(targetOf(file, "/wt"), "main"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("renameTargetBranch honours `scope`, leaving other repos' same-named targets", () => { + // The store is global and branch names are not unique across repos, so a rename + // in one repo must not rewrite an identically-named target in another. + const { dir, file } = tmpFile(); + try { + putTarget(file, "/repo-a/wt", "develop"); + putTarget(file, "/repo-b/wt", "develop"); + const moved = renameTargetBranch(file, "develop", "main", new Set(["/repo-a/wt"])); + assert.equal(moved, 1, "only the in-scope worktree moves"); + assert.equal(targetOf(file, "/repo-a/wt"), "main"); + assert.equal(targetOf(file, "/repo-b/wt"), "develop", "the other repo is untouched"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("renameTargetBranch returns null (not 0) when the write is refused", () => { + // `0` means "nothing pointed at the old name" — a success. A refused write must + // be distinguishable, or the caller acks a rename that never reached disk. + const { dir } = tmpFile(); + try { + const blocker = join(dir, "blocker"); + writeFileSync(blocker, "not a directory"); + const file = join(blocker, "sub", "worktree-targets.json"); + // Nothing is stored (the store is unreadable), so nothing matches -> 0, not null. + assert.equal(renameTargetBranch(file, "a", "b"), 0, "no candidates is still a success"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/server/src/worktree-target-store.ts b/server/src/worktree-target-store.ts new file mode 100644 index 00000000..0d539de4 --- /dev/null +++ b/server/src/worktree-target-store.ts @@ -0,0 +1,240 @@ +/** + * worktree-target-store — persistence for each worktree's **target branch**: + * the branch its work is destined for, which decides what the `+N −M` diff + * measures, what a PR targets, and what a wrap-up fast-forwards. + * + * Why a new store at all: nothing in makit persisted per-worktree state. + * `projects.json` holds `{ id, path }` records only, and worktrees are + * enumerated live from `git worktree list` on every snapshot — so the base + * branch the user picked at creation time was used once for `git worktree add` + * and then discarded. This is the missing home for that answer. + * + * Keyed by **absolute worktree path**, which is the same identity + * `WorktreeDTO.id` uses. Path-keying survives a branch rename (`renameBranch` + * keeps the path) but it does NOT survive a move, and — the sharp edge — a + * removed-and-recreated worktree lands on the *same* path, because + * `addWorktree` builds `//` deterministically. Without + * {@link pruneTargets} the new worktree would silently inherit the dead one's + * target. Callers must prune against the live set. + * + * Like `project-store`, load/save never throw: a corrupt or unreadable file + * degrades to "no targets" so the server always starts, and a failed write is + * logged and swallowed. Unlike `project-store`, the write is **atomic** + * (temp file + rename), because a torn read-modify-write here would silently + * lose or corrupt a value that decides where code gets merged. + */ + +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { makitHome } from "./daemon/paths.js"; +import { log } from "./log.js"; + +/** + * One worktree's target, plus what it used to be when makit changed it on the + * user's behalf. + */ +export interface TargetEntry { + /** The branch this worktree's work lands in. */ + target: string; + /** + * The target this replaced, when the change was **automatic** — a branch we + * were aiming at disappeared and we fell back to the repo default. + * + * Kept so the change can be *announced* rather than done behind the user's + * back: a silent repoint would move a worktree's diff and its future pull + * request to a different destination with no trace. Cleared the moment the user + * chooses a target explicitly, because by then they own the value and there is + * nothing left to tell them. + */ + retargetedFrom?: string; +} + +/** Worktree absolute path → its target entry. */ +export type TargetMap = Record; + +/** Absolute path of the target-branch persistence file. */ +export function worktreeTargetsFile(): string { + return process.env.MAKIT_WORKTREE_TARGETS_FILE ?? join(makitHome(), "worktree-targets.json"); +} + +/** + * Read the persisted map. A missing, unreadable or malformed file yields `{}`. + * + * Entries are validated individually: one bad value skips only that key rather + * than discarding every other worktree's target (the same isolation + * `loadProjects` learned to apply). An empty-string branch is treated as absent + * so a truncated write cannot resolve to a ref named "". + */ +export function loadTargets(file: string): TargetMap { + try { + if (!existsSync(file)) return {}; + const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown; + if (typeof parsed !== "object" || parsed === null) return {}; + const raw = (parsed as { targets?: unknown }).targets; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {}; + const out: TargetMap = {}; + for (const [path, value] of Object.entries(raw as Record)) { + if (typeof path !== "string" || !path) continue; + // v1 of this file stored a bare branch string. Read it so upgrading does + // not silently drop every worktree's target. + if (typeof value === "string") { + if (value) out[path] = { target: value }; + continue; + } + if (typeof value !== "object" || value === null) continue; + const { target, retargetedFrom } = value as Partial; + if (typeof target !== "string" || !target) continue; + out[path] = + typeof retargetedFrom === "string" && retargetedFrom + ? { target, retargetedFrom } + : { target }; + } + return out; + } catch (e) { + log.warn(`[makit] failed to read worktree targets ${file}: ${(e as Error).message}`); + return {}; + } +} + +/** + * Persist the map atomically: write a sibling temp file, then `rename` it over + * the destination. `rename` within a directory is atomic on POSIX, so a reader + * only ever sees the old file or the new one — never a half-written one. + * + * Never throws — a broken disk must not crash a background snapshot repair. It + * does, however, RETURN whether the write landed: an interactive command + * (`worktree.setTarget`) needs to tell the user the truth rather than ack a + * success the next snapshot will contradict. A failed write is logged and the + * temp file cleaned up so a broken disk does not leave litter beside the store. + */ +export function saveTargets(file: string, targets: TargetMap): boolean { + const tmp = `${file}.tmp`; + try { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(tmp, JSON.stringify({ targets }, null, 2) + "\n"); + renameSync(tmp, file); + return true; + } catch (e) { + log.warn(`[makit] failed to write worktree targets ${file}: ${(e as Error).message}`); + try { + if (existsSync(tmp)) unlinkSync(tmp); + } catch { + // Best-effort cleanup; the write already failed and we must not throw. + } + return false; + } +} + +/** The target branch recorded for `worktreePath`, or null. */ +export function targetOf(file: string, worktreePath: string): string | null { + return loadTargets(file)[worktreePath]?.target ?? null; +} + +/** + * Record `branch` as the target for `worktreePath`, leaving other keys alone. + * + * Omitting `retargetedFrom` CLEARS any existing note, which is what an explicit + * user choice should do: they have taken ownership of the value, so there is no + * longer an automatic change to announce. + * + * `expect` makes the write a **compare-and-set**: it is the target the caller's + * decision was based on, and the write is skipped when the store has since moved + * on. Background reconciliation (adopt / repair / hand-down) decides what to + * write from a map it read BEFORE its own async git reads, so a user's + * `worktree.setTarget` can land in that window — and the user's explicit choice + * must win over a stale automatic one. Pass `null` to mean "expected no entry". + * + * Returns whether the value is now stored as asked. `false` means either the + * write failed or the CAS was refused; both mean "do not treat this as applied". + */ +export function putTarget( + file: string, + worktreePath: string, + branch: string, + opts: { retargetedFrom?: string; expect?: string | null } = {}, +): boolean { + const all = loadTargets(file); + if (opts.expect !== undefined && (all[worktreePath]?.target ?? null) !== opts.expect) { + return false; + } + all[worktreePath] = opts.retargetedFrom + ? { target: branch, retargetedFrom: opts.retargetedFrom } + : { target: branch }; + return saveTargets(file, all); +} + +/** + * Follow a branch rename: every worktree that landed in `oldName` now lands in + * `newName`. Returns how many moved. + * + * Without this, renaming a branch leaves every worktree aiming at it pointing at + * a name that no longer resolves — the diff becomes unmeasurable and the + * worktree looks broken, for a rename that was none of its business. Any + * `retargetedFrom` note is preserved: the rename does not change the fact that we + * had already moved that worktree once. + * + * Writes only when something changed, so a rename of an untargeted branch does + * not churn the file. + * + * Returns how many moved, or **`null` when the write was refused** — `0` alone + * could not distinguish "nothing pointed at the old name" (a success) from "the + * store is not writable" (a silent divergence, where every dependent worktree + * keeps aiming at a branch name that no longer exists). + * + * [scope], when given, restricts the rewrite to those worktree paths. The store + * is GLOBAL across every project, and branch names are not unique across repos, + * so a caller renaming a branch in one repo must pass its own worktree paths or + * it would silently rewrite a same-named target in an unrelated repo. + */ +export function renameTargetBranch( + file: string, + oldName: string, + newName: string, + scope?: ReadonlySet, +): number | null { + if (!oldName || !newName || oldName === newName) return 0; + const all = loadTargets(file); + let moved = 0; + for (const [path, entry] of Object.entries(all)) { + if (entry.target !== oldName) continue; + if (scope && !scope.has(path)) continue; + all[path] = { ...entry, target: newName }; + moved++; + } + if (moved > 0 && !saveTargets(file, all)) return null; + return moved; +} + +/** Forget `worktreePath`'s target. A key that is already absent is a no-op. + * Returns whether the store is consistent on disk afterwards (a no-op is `true`), + * so `removeWorktree` can tell when a stale entry may survive a failed write. */ +export function clearTarget(file: string, worktreePath: string): boolean { + const all = loadTargets(file); + if (!(worktreePath in all)) return true; + delete all[worktreePath]; + return saveTargets(file, all); +} + +/** + * Drop entries for worktrees that are no longer live, and return how many went. + * + * This is not housekeeping — it is correctness. Worktree paths are derived + * deterministically from repo + name, so `rm`-ing a worktree and creating + * another with the same name reuses the path; a surviving entry would hand the + * new worktree the old one's merge destination. + * + * Writes only when something actually changed, so calling it on every snapshot + * does not churn the file (or its mtime) for no reason. + */ +export function pruneTargets(file: string, livePaths: readonly string[]): number { + const all = loadTargets(file); + const live = new Set(livePaths); + const stale = Object.keys(all).filter((p) => !live.has(p)); + if (stale.length === 0) return 0; + for (const p of stale) delete all[p]; + // Report zero pruned when the write is refused, for the same reason as + // `renameTargetBranch`: a caller must not trust an on-disk change that did not + // land. + if (!saveTargets(file, all)) return 0; + return stale.length; +} diff --git a/server/src/ws/commands/worktree.ts b/server/src/ws/commands/worktree.ts index 4ef22645..15b77fa8 100644 --- a/server/src/ws/commands/worktree.ts +++ b/server/src/ws/commands/worktree.ts @@ -13,14 +13,22 @@ export function register(r: CommandRouter, deps: CommandDeps): void { r.register("worktree.create", async (ctx) => { const projectId = String(ctx.env.projectId ?? ""); - const baseBranch = ctx.env.baseBranch ? String(ctx.env.baseBranch) : undefined; + // Same rename + one-release alias as `worktree.wrapUp` below. Benign here by + // comparison (a wrong value forks from the wrong place, which is visible and + // deletable) but kept consistent so there is one vocabulary on the wire. + // `||` (not `??`): an explicit empty string must fall through to the alias + // and then to `undefined`, exactly as the old nested ternary did. + // TODO(SPEC-51): drop the `baseBranch` alias one release after the app ships + // with `targetBranch`. + const rawTarget = ctx.env.targetBranch || ctx.env.baseBranch; + const targetBranch = rawTarget ? String(rawTarget) : undefined; const branchName = ctx.env.branchName ? String(ctx.env.branchName) : undefined; if (!projectId) { ctx.err(WireErrorCode.BadRequest, "worktree.create requires a projectId"); return; } try { - const wt = await manager.createWorktree(projectId, baseBranch, branchName); + const wt = await manager.createWorktree(projectId, targetBranch, branchName); void broadcastReposSnapshot(); ctx.ack({ projectId, path: wt.path, branch: wt.branch }); } catch (e) { @@ -95,7 +103,7 @@ export function register(r: CommandRouter, deps: CommandDeps): void { // which keeps the branch (the sidebar and the mobile long-press use that one): // "remove this worktree" is a narrower request than "discard this dead line of // work". No base-branch leg — nothing landed, so there is nothing to catch up, - // and the ack's `baseUpdated` is always false. + // and the ack's `targetUpdated` is always false. r.register("worktree.discard", async (ctx) => { const projectId = String(ctx.env.projectId ?? ""); const worktreePath = String(ctx.env.worktreePath ?? ""); @@ -115,8 +123,63 @@ export function register(r: CommandRouter, deps: CommandDeps): void { } }); + // Ranked candidates for the "Lands in" picker. A read, so no broadcast: opening + // a picker must not push a snapshot to every client. + r.register("worktree.targetCandidates", async (ctx) => { + const projectId = String(ctx.env.projectId ?? ""); + const worktreePath = String(ctx.env.worktreePath ?? ""); + if (!projectId || !worktreePath) { + ctx.err( + WireErrorCode.BadRequest, + "worktree.targetCandidates requires projectId and worktreePath", + ); + return; + } + try { + const candidates = await manager.targetCandidates(projectId, worktreePath); + ctx.ack({ projectId, worktreePath, candidates }); + } catch (e) { + ctx.err(WireErrorCode.BadRequest, (e as Error).message); + } + }); + + // Set the branch a worktree's work lands in: what the +/- diff measures against + // (`git diff target...HEAD`, i.e. what a PR into it would contain), what + // `gh pr create --base` will target, and what a wrap-up fast-forwards. + // + // Ordering is the whole contract here (R1/R2). The manager PERSISTS the new + // target before we start the broadcast: persisting after the broadcast would + // recompute the snapshot against the OLD target and ship stale numbers that + // then look correct until some unrelated event moved them. + // The broadcast is `void` (fire-and-forget, like all eight sibling commands; + // awaiting it would block the ack on a full git pass), so the ack actually + // returns first. That is safe: the snapshot it will ship is computed from the + // already-persisted target, so a client that re-enables its picker on the ack + // repaints against the new figures, not the previous ones. + // Deliberately NOT throttled: `throttledReposSnapshot` exists to coalesce + // turn-end churn, and a user-initiated change must land immediately. + r.register("worktree.setTarget", async (ctx) => { + const projectId = String(ctx.env.projectId ?? ""); + const worktreePath = String(ctx.env.worktreePath ?? ""); + const targetBranch = ctx.env.targetBranch ? String(ctx.env.targetBranch) : undefined; + if (!projectId || !worktreePath || !targetBranch) { + ctx.err( + WireErrorCode.BadRequest, + "worktree.setTarget requires projectId, worktreePath and targetBranch", + ); + return; + } + try { + const result = await manager.setWorktreeTarget(projectId, worktreePath, targetBranch); + void broadcastReposSnapshot(); + ctx.ack({ projectId, ...result }); + } catch (e) { + ctx.err(WireErrorCode.BadRequest, (e as Error).message); + } + }); + // The ending a merged PR never had: remove the worktree, delete its branch, and - // fast-forward the branch the PR landed on. `baseBranch` is the PR's own + // fast-forward the branch the PR landed on. `targetBranch` is the PR's own // baseRefName when the app has it; the manager falls back to the repo default. // // The ack carries what actually happened (which branch went, whether the base @@ -126,7 +189,17 @@ export function register(r: CommandRouter, deps: CommandDeps): void { r.register("worktree.wrapUp", async (ctx) => { const projectId = String(ctx.env.projectId ?? ""); const worktreePath = String(ctx.env.worktreePath ?? ""); - const baseBranch = ctx.env.baseBranch ? String(ctx.env.baseBranch) : undefined; + // `targetBranch` is the name; `baseBranch` is read for ONE release as a + // compatibility shim, and it is not cosmetic. A client that predates the + // rename sends the old key, and the manager's `?? detectDefaultBranch()` + // fallback would then silently fast-forward the WRONG branch and ack it as a + // success -- the one irreversible failure in this rename. Delete the alias a + // release after the app ships with the new key. `||` keeps the old nested + // ternary's truthiness semantics (an empty string falls through). + // TODO(SPEC-51): drop the `baseBranch` alias one release after the app ships + // with `targetBranch`. + const rawTarget = ctx.env.targetBranch || ctx.env.baseBranch; + const targetBranch = rawTarget ? String(rawTarget) : undefined; const expectBranch = ctx.env.expectBranch ? String(ctx.env.expectBranch) : undefined; if (!projectId || !worktreePath) { ctx.err(WireErrorCode.BadRequest, "worktree.wrapUp requires projectId and worktreePath"); @@ -136,7 +209,7 @@ export function register(r: CommandRouter, deps: CommandDeps): void { const result = await manager.wrapUpWorktree( projectId, worktreePath, - baseBranch, + targetBranch, expectBranch, ); void broadcastReposSnapshot(); diff --git a/server/test/ws/auto_mirror.test.ts b/server/test/ws/auto_mirror.test.ts index 452be7db..7b9e0195 100644 --- a/server/test/ws/auto_mirror.test.ts +++ b/server/test/ws/auto_mirror.test.ts @@ -82,6 +82,9 @@ function repoSnapshot(insertions: number, prNumber: number | null): RepoDTO[] { path: "/repo-feature", branch: "feature", isPrimary: false, + targetBranch: "main", + targetResolved: true, + retargetedFrom: null, insertions, deletions: 0, filesChanged: 1, diff --git a/server/test/ws/pr_commands.test.ts b/server/test/ws/pr_commands.test.ts index 2fee15fd..23b0ed71 100644 --- a/server/test/ws/pr_commands.test.ts +++ b/server/test/ws/pr_commands.test.ts @@ -82,48 +82,83 @@ const OK = { projectId: "p1", worktreePath: "/wt/x" }; test("worktree.wrapUp acks the whole report the app decodes", async () => { // Every field matters: `WrapUpReport.summary` builds its line from - // branchDeleted + baseBranch + baseUpdated, and the "Why?" action needs - // baseReason. Dropping any of them degrades the message silently. + // branchDeleted + targetBranch + targetUpdated, and the "Why?" action needs + // targetReason. Dropping any of them degrades the message silently. const { router, client, broadcasts } = routerWith({ wrapUpWorktree: async () => ({ branchDeleted: "feat/x", - baseBranch: "main", - baseUpdated: false, - baseReason: "main has local commits that are not on origin/main", + targetBranch: "main", + targetUpdated: false, + targetReason: "main has local commits that are not on origin/main", }), }); - await router.dispatch(client, cmd("worktree.wrapUp", { ...OK, baseBranch: "main" })); + await router.dispatch(client, cmd("worktree.wrapUp", { ...OK, targetBranch: "main" })); const ack = ackOf(client); assert.ok(ack, "expected an ack"); assert.equal(ack.projectId, "p1"); assert.equal(ack.worktreePath, "/wt/x"); assert.equal(ack.branchDeleted, "feat/x"); - assert.equal(ack.baseBranch, "main"); - assert.equal(ack.baseUpdated, false); - assert.match(String(ack.baseReason), /local commits/); + assert.equal(ack.targetBranch, "main"); + assert.equal(ack.targetUpdated, false); + assert.match(String(ack.targetReason), /local commits/); assert.equal(broadcasts(), 1, "the row must refresh"); }); -test("worktree.wrapUp forwards the PR's own base branch", async () => { - // The base is not always `main`; passing it is what makes a release-branch PR +test("worktree.wrapUp forwards the PR's own target branch", async () => { + // The target is not always `main`; passing it is what makes a release-branch PR // fast-forward the right ref. const seen: unknown[] = []; const { router, client } = routerWith({ - wrapUpWorktree: async (_p: string, _w: string, base?: string) => { - seen.push(base); - return { baseUpdated: true }; + wrapUpWorktree: async (_p: string, _w: string, target?: string) => { + seen.push(target); + return { targetUpdated: true }; }, }); await router.dispatch( client, - cmd("worktree.wrapUp", { ...OK, baseBranch: "release/2.0" }), + cmd("worktree.wrapUp", { ...OK, targetBranch: "release/2.0" }), ); assert.deepEqual(seen, ["release/2.0"]); }); +test("worktree.wrapUp still honours the legacy baseBranch key", async () => { + // The one irreversible failure in the base->target rename: a client that + // predates it sends `baseBranch`, the server reads undefined, and the manager's + // `?? detectDefaultBranch()` fallback then fast-forwards the WRONG branch and + // acks it as a success. This alias is the guard, so it needs a test that fails + // the day someone deletes it without shipping the app first. + const seen: unknown[] = []; + const { router, client } = routerWith({ + wrapUpWorktree: async (_p: string, _w: string, target?: string) => { + seen.push(target); + return { targetUpdated: true }; + }, + }); + await router.dispatch( + client, + cmd("worktree.wrapUp", { ...OK, baseBranch: "release/2.0" }), + ); + assert.deepEqual(seen, ["release/2.0"], "a stale client must not fall through to the default"); +}); + +test("worktree.wrapUp prefers targetBranch when a client sends both", async () => { + const seen: unknown[] = []; + const { router, client } = routerWith({ + wrapUpWorktree: async (_p: string, _w: string, target?: string) => { + seen.push(target); + return { targetUpdated: true }; + }, + }); + await router.dispatch( + client, + cmd("worktree.wrapUp", { ...OK, baseBranch: "old", targetBranch: "new" }), + ); + assert.deepEqual(seen, ["new"]); +}); + test("worktree.discard acks the branch it deleted", async () => { const { router, client, broadcasts } = routerWith({ - discardWorktree: async () => ({ branchDeleted: "feat/x", baseUpdated: false }), + discardWorktree: async () => ({ branchDeleted: "feat/x", targetUpdated: false }), }); await router.dispatch(client, cmd("worktree.discard", OK)); assert.equal(ackOf(client).branchDeleted, "feat/x"); diff --git a/server/test/ws/worktree_set_target.test.ts b/server/test/ws/worktree_set_target.test.ts new file mode 100644 index 00000000..91bb83d9 --- /dev/null +++ b/server/test/ws/worktree_set_target.test.ts @@ -0,0 +1,257 @@ +/** + * The bug this whole feature exists for, end to end. + * + * A worktree stacked on another worktree's branch reported the PARENT's work as + * its own, because `repo_service` handed the repo's default branch to `diffStat` + * for every worktree regardless of what that worktree was actually destined for + * (`diffStat(e.path, defaultBranch)`). The base the user picked at creation time + * was used once for `git worktree add` and then discarded. + * + * This drives a real {@link startWsServer} against a real git repo with a real + * two-level stack and asserts: + * * the child's diff is inflated by the parent's commits while it targets `main`, + * * `worktree.setTarget` collapses it to the child's own delta, + * * the new numbers arrive on the very next `repos.snapshot` with no further + * action (R1: persist happens before the broadcast, so the snapshot that + * follows cannot have been computed against the old target), + * * an unknown ref is refused (R10) and leaves the stored target untouched. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AddressInfo } from "node:net"; +import { WebSocket } from "ws"; + +import { SessionManager } from "../../src/manager.js"; +import { startWsServer } from "../../src/server.js"; +import { loadOrCreateCert } from "../../src/pairing/cert.js"; +import { DeviceRegistry } from "../../src/pairing/registry.js"; +import { StubAdapter } from "../../src/adapters/stub.js"; + +interface Client { + ws: WebSocket; + msgs: Record[]; +} + +function connect(port: number): Client { + const ws = new WebSocket(`wss://127.0.0.1:${port}`, { rejectUnauthorized: false }); + const msgs: Record[] = []; + ws.on("message", (b: Buffer) => { + try { + msgs.push(JSON.parse(b.toString())); + } catch { + /* ignore non-JSON */ + } + }); + return { ws, msgs }; +} + +function waitOpen(ws: WebSocket): Promise { + return new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); +} + +async function waitFor(pred: () => boolean, label = "condition", timeoutMs = 8000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (pred()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`timeout waiting for ${label}`); +} + +interface WtDTO { + path: string; + branch: string | null; + targetBranch: string | null; + targetResolved: boolean; + insertions: number; + deletions: number; +} + +const isReposSnapshot = (m: Record) => + m.t === "event" && m.kind === "repos.snapshot"; + +/** The worktrees from the most recent `repos.snapshot`. */ +function latestWorktrees(c: Client): WtDTO[] { + const snaps = c.msgs.filter(isReposSnapshot); + const last = snaps[snaps.length - 1]!; + const repos = last.repos as Array<{ worktrees: WtDTO[] }>; + return repos.flatMap((r) => r.worktrees); +} + +function byBranch(c: Client, branch: string): WtDTO { + const wt = latestWorktrees(c).find((w) => w.branch === branch); + assert.ok(wt, `no worktree on branch ${branch}; saw ${latestWorktrees(c).map((w) => w.branch).join(", ")}`); + return wt; +} + +/** Wait for the snapshot burst to settle, then return the count seen. */ +async function settle(c: Client, timeoutMs = 8000): Promise { + await waitFor(() => c.msgs.filter(isReposSnapshot).length > 0, "first repos.snapshot"); + // Bound the quiet loop with a deadline: if snapshot frames keep arriving faster + // than the 120ms quiet window, resetting `quiet` on every change would spin + // forever. A deadline turns that into a readable failure instead of a hang. + const deadline = Date.now() + timeoutMs; + let stable = -1; + for (let quiet = 0; quiet < 4; quiet++) { + if (Date.now() > deadline) { + throw new Error(`settle: repos.snapshot stream never went quiet within ${timeoutMs}ms`); + } + await new Promise((r) => setTimeout(r, 120)); + const n = c.msgs.filter(isReposSnapshot).length; + if (n !== stable) { + stable = n; + quiet = -1; + } + } + return stable; +} + +test("setTarget retargets a stacked worktree's diff to its parent", async () => { + const home = mkdtempSync(join(tmpdir(), "makit-target-home-")); + const project = mkdtempSync(join(tmpdir(), "makit-target-proj-")); + const wtDir = mkdtempSync(join(tmpdir(), "makit-target-wt-")); + const prevHome = process.env.MAKIT_HOME; + const prevWtDir = process.env.MAKIT_WORKTREE_DIR; + process.env.MAKIT_HOME = home; + process.env.MAKIT_WORKTREE_DIR = wtDir; + + const g = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd }); + // A repo on `main` with one commit. + g(project, "init", "-q", "-b", "main"); + g(project, "config", "user.email", "t@t.io"); + g(project, "config", "user.name", "Test"); + writeFileSync(join(project, "README.md"), "base\n"); + g(project, "add", "."); + g(project, "commit", "-q", "-m", "initial"); + + // The PARENT branch: 20 added lines, committed. + const parentPath = join(wtDir, "parent"); + g(project, "worktree", "add", "-q", "-b", "feat/parent", parentPath, "main"); + writeFileSync(join(parentPath, "parent.txt"), Array.from({ length: 20 }, (_, i) => `p${i}`).join("\n") + "\n"); + g(parentPath, "add", "."); + g(parentPath, "commit", "-q", "-m", "parent work"); + + // The CHILD branch, forked off the parent: 3 more lines. + const childPath = join(wtDir, "child"); + g(project, "worktree", "add", "-q", "-b", "feat/child", childPath, "feat/parent"); + writeFileSync(join(childPath, "child.txt"), "c0\nc1\nc2\n"); + g(childPath, "add", "."); + g(childPath, "commit", "-q", "-m", "child work"); + + const manager = new SessionManager({ + projects: [project], + adapterFactory: () => new StubAdapter(), + }); + const cert = await loadOrCreateCert(); + const srv = startWsServer({ + host: "127.0.0.1", + port: 0, + manager, + cert, + registry: new DeviceRegistry(), + trustLocalhost: true, + }); + await new Promise((resolve) => { + if (srv.https.listening) resolve(); + else srv.https.once("listening", () => resolve()); + }); + const port = (srv.https.address() as AddressInfo).port; + + const c = connect(port); + try { + await waitOpen(c.ws); + await settle(c); + + // ── Before: the child is measured against `main`, so it carries the + // parent's 20 lines plus its own 3. + const childBefore = byBranch(c, "feat/child"); + assert.equal(childBefore.targetBranch, "main", "defaults to the repo default (safe upgrade seed)"); + assert.equal(childBefore.targetResolved, true); + assert.equal( + childBefore.insertions, + 23, + `the stacked worktree should be inflated by its parent's work, got ${childBefore.insertions}`, + ); + + // ── Retarget the child at its actual parent. + // Use the path from the SNAPSHOT, not our local `childPath`: git reports + // worktree paths symlink-resolved (on macOS `/var` -> `/private/var`), and + // `_locateWorktree` matches on that. The app is never in a position to send + // anything else, since every path it knows came from a snapshot. + const childId = childBefore.path; + const before = c.msgs.filter(isReposSnapshot).length; + c.ws.send( + JSON.stringify({ + v: 1, + t: "cmd", + id: "set-1", + kind: "worktree.setTarget", + projectId: manager.listProjects()[0]!.id, + worktreePath: childId, + targetBranch: "feat/parent", + }), + ); + await waitFor( + () => c.msgs.some((m) => (m.t === "ack" || m.t === "err") && m.id === "set-1"), + "setTarget reply", + ); + const reply = c.msgs.find((m) => m.id === "set-1")!; + assert.equal(reply.t, "ack", `setTarget failed: ${JSON.stringify(reply)}`); + assert.equal(reply.targetBranch, "feat/parent", `unexpected ack: ${JSON.stringify(reply)}`); + + // ── After: the very next snapshot carries the corrected numbers. If the + // persist had happened after the broadcast (R1 violated) this frame would + // still say 23. + await waitFor(() => c.msgs.filter(isReposSnapshot).length > before, "post-setTarget snapshot"); + await settle(c); + const childAfter = byBranch(c, "feat/child"); + assert.equal(childAfter.targetBranch, "feat/parent"); + assert.equal(childAfter.targetResolved, true); + assert.equal( + childAfter.insertions, + 3, + `retargeting should leave only the child's own delta, got ${childAfter.insertions}`, + ); + + // The parent is untouched: retargeting one worktree must not disturb another. + const parent = byBranch(c, "feat/parent"); + assert.equal(parent.targetBranch, "main"); + assert.equal(parent.insertions, 20); + + // ── R10: an unknown ref is refused, and the stored target does not move. + c.ws.send( + JSON.stringify({ + v: 1, + t: "cmd", + id: "set-bad", + kind: "worktree.setTarget", + projectId: manager.listProjects()[0]!.id, + worktreePath: childId, + targetBranch: "no/such/branch", + }), + ); + await waitFor(() => c.msgs.some((m) => m.t === "err" && m.id === "set-bad"), "setTarget rejection"); + await settle(c); + assert.equal(byBranch(c, "feat/child").targetBranch, "feat/parent", "a rejected setTarget must not change anything"); + } finally { + c.ws.close(); + srv.https.close(); + srv.wss.close(); + + if (prevHome === undefined) delete process.env.MAKIT_HOME; + else process.env.MAKIT_HOME = prevHome; + if (prevWtDir === undefined) delete process.env.MAKIT_WORKTREE_DIR; + else process.env.MAKIT_WORKTREE_DIR = prevWtDir; + rmSync(home, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + rmSync(wtDir, { recursive: true, force: true }); + } +});