1import { EnvironmentId, type VcsRef } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3import {
4 dedupeRemoteBranchesWithLocalMatches,
5 deriveLocalBranchNameFromRemoteRef,
6 resolveEnvironmentOptionLabel,
7 resolveBranchSelectionTarget,
8 resolveCurrentWorkspaceLabel,
9 resolveDraftEnvModeAfterBranchChange,
10 resolveEffectiveEnvMode,
11 resolveEnvModeLabel,
12 resolveBranchTriggerLabel,
13 resolveBranchToolbarPrBranch,
14 resolveBranchToolbarValue,
15 resolveLockedWorkspaceLabel,
16 resolveLocalCheckoutBranchMismatch,
17 resolvePreviousWorktreeLabel,
18 resolvePreviousWorktreeSeed,
19 sanitizeNewRefName,
20 shouldIncludeBranchPickerItem,
21 shouldShowComposerContextStrip,
22 shouldShowEnvironmentIndicator,
23} from "./BranchToolbar.logic";
24
25const localEnvironmentId = EnvironmentId.make("environment-local");
26const remoteEnvironmentId = EnvironmentId.make("environment-remote");
27
28describe("resolvePreviousWorktreeSeed", () => {
29 it("picks the most recently updated worktree thread", () => {
30 expect(
31 resolvePreviousWorktreeSeed({
32 threads: [
33 {
34 branch: "t3/older",
35 worktreePath: "/repo/.t3/worktrees/older",
36 updatedAt: "2026-07-20T00:00:00.000Z",
37 },
38 {
39 branch: "t3/newer",
40 worktreePath: "/repo/.t3/worktrees/newer",
41 updatedAt: "2026-07-22T00:00:00.000Z",
42 },
43 { branch: "main", worktreePath: null, updatedAt: "2026-07-23T00:00:00.000Z" },
44 ],
45 currentWorktreePath: null,
46 }),
47 ).toEqual({ branch: "t3/newer", worktreePath: "/repo/.t3/worktrees/newer" });
48 });
49
50 it("skips the worktree the composer already points at", () => {
51 expect(
52 resolvePreviousWorktreeSeed({
53 threads: [
54 {
55 branch: "t3/current",
56 worktreePath: "/repo/.t3/worktrees/current",
57 updatedAt: "2026-07-22T00:00:00.000Z",
58 },
59 ],
60 currentWorktreePath: "/repo/.t3/worktrees/current",
61 }),
62 ).toBeNull();
63 });
64
65 it("returns null when no thread has a worktree", () => {
66 expect(
67 resolvePreviousWorktreeSeed({
68 threads: [{ branch: "main", worktreePath: null, updatedAt: "2026-07-22T00:00:00.000Z" }],
69 currentWorktreePath: null,
70 }),
71 ).toBeNull();
72 });
73
74 it("ignores archived threads and threads with unparseable timestamps", () => {
75 expect(
76 resolvePreviousWorktreeSeed({
77 threads: [
78 {
79 branch: "t3/archived",
80 worktreePath: "/repo/.t3/worktrees/archived",
81 updatedAt: "2026-07-23T00:00:00.000Z",
82 archivedAt: "2026-07-23T01:00:00.000Z",
83 },
84 {
85 branch: "t3/garbage-timestamp",
86 worktreePath: "/repo/.t3/worktrees/garbage",
87 updatedAt: "not-a-date",
88 },
89 {
90 branch: "t3/live",
91 worktreePath: "/repo/.t3/worktrees/live",
92 updatedAt: "2026-07-21T00:00:00.000Z",
93 archivedAt: null,
94 },
95 ],
96 currentWorktreePath: null,
97 }),
98 ).toEqual({ branch: "t3/live", worktreePath: "/repo/.t3/worktrees/live" });
99 });
100});
101
102describe("resolvePreviousWorktreeLabel", () => {
103 it("includes the branch when known", () => {
104 expect(resolvePreviousWorktreeLabel({ branch: "t3/fix-thing", worktreePath: "/wt" })).toBe(
105 "Previous worktree (t3/fix-thing)",
106 );
107 expect(resolvePreviousWorktreeLabel({ branch: null, worktreePath: "/wt" })).toBe(
108 "Previous worktree",
109 );
110 });
111});
112
113describe("resolveDraftEnvModeAfterBranchChange", () => {
114 it("switches to local mode when returning from an existing worktree to the main worktree", () => {
115 expect(
116 resolveDraftEnvModeAfterBranchChange({
117 nextWorktreePath: null,
118 currentWorktreePath: "/repo/.t3/worktrees/feature-a",
119 effectiveEnvMode: "worktree",
120 }),
121 ).toBe("local");
122 });
123
124 it("keeps new-worktree mode when selecting a base ref before worktree creation", () => {
125 expect(
126 resolveDraftEnvModeAfterBranchChange({
127 nextWorktreePath: null,
128 currentWorktreePath: null,
129 effectiveEnvMode: "worktree",
130 }),
131 ).toBe("worktree");
132 });
133
134 it("uses worktree mode when selecting a ref already attached to a worktree", () => {
135 expect(
136 resolveDraftEnvModeAfterBranchChange({
137 nextWorktreePath: "/repo/.t3/worktrees/feature-a",
138 currentWorktreePath: null,
139 effectiveEnvMode: "local",
140 }),
141 ).toBe("worktree");
142 });
143});
144
145describe("resolveBranchToolbarValue", () => {
146 it("defaults new-worktree mode to current git ref when no explicit base ref is set", () => {
147 expect(
148 resolveBranchToolbarValue({
149 envMode: "worktree",
150 activeWorktreePath: null,
151 activeThreadBranch: null,
152 currentGitBranch: "main",
153 }),
154 ).toBe("main");
155 });
156
157 it("keeps an explicitly selected worktree base ref", () => {
158 expect(
159 resolveBranchToolbarValue({
160 envMode: "worktree",
161 activeWorktreePath: null,
162 activeThreadBranch: "feature/base",
163 currentGitBranch: "main",
164 }),
165 ).toBe("feature/base");
166 });
167
168 it("shows the actual checked-out ref when not selecting a new worktree base", () => {
169 expect(
170 resolveBranchToolbarValue({
171 envMode: "local",
172 activeWorktreePath: null,
173 activeThreadBranch: "feature/base",
174 currentGitBranch: "main",
175 }),
176 ).toBe("main");
177 });
178});
179
180describe("resolveBranchTriggerLabel", () => {
181 it("shows the origin ref when a new worktree will start from origin", () => {
182 expect(
183 resolveBranchTriggerLabel({
184 activeWorktreePath: null,
185 effectiveEnvMode: "worktree",
186 resolvedActiveBranch: "main",
187 resolvedActiveBranchIsRemote: false,
188 startFromOrigin: true,
189 }),
190 ).toBe("From origin/main");
191 });
192
193 it("shows the origin ref for local branch names that contain slashes", () => {
194 expect(
195 resolveBranchTriggerLabel({
196 activeWorktreePath: null,
197 effectiveEnvMode: "worktree",
198 resolvedActiveBranch: "feature/demo",
199 resolvedActiveBranchIsRemote: false,
200 startFromOrigin: true,
201 }),
202 ).toBe("From origin/feature/demo");
203 });
204
205 it("shows the local ref when start from origin is disabled", () => {
206 expect(
207 resolveBranchTriggerLabel({
208 activeWorktreePath: null,
209 effectiveEnvMode: "worktree",
210 resolvedActiveBranch: "main",
211 resolvedActiveBranchIsRemote: false,
212 startFromOrigin: false,
213 }),
214 ).toBe("From main");
215 });
216
217 it("does not duplicate the origin prefix for an explicit remote ref", () => {
218 expect(
219 resolveBranchTriggerLabel({
220 activeWorktreePath: null,
221 effectiveEnvMode: "worktree",
222 resolvedActiveBranch: "origin/feature/demo",
223 resolvedActiveBranchIsRemote: true,
224 startFromOrigin: true,
225 }),
226 ).toBe("From origin/feature/demo");
227 });
228
229 it("preserves an explicit ref from a non-origin remote", () => {
230 expect(
231 resolveBranchTriggerLabel({
232 activeWorktreePath: null,
233 effectiveEnvMode: "worktree",
234 resolvedActiveBranch: "upstream/feature/demo",
235 resolvedActiveBranchIsRemote: true,
236 startFromOrigin: true,
237 }),
238 ).toBe("From upstream/feature/demo");
239 });
240
241 it("keeps current-checkout labels and empty state unchanged", () => {
242 expect(
243 resolveBranchTriggerLabel({
244 activeWorktreePath: null,
245 effectiveEnvMode: "local",
246 resolvedActiveBranch: "main",
247 resolvedActiveBranchIsRemote: false,
248 startFromOrigin: true,
249 }),
250 ).toBe("main");
251 expect(
252 resolveBranchTriggerLabel({
253 activeWorktreePath: null,
254 effectiveEnvMode: "worktree",
255 resolvedActiveBranch: null,
256 resolvedActiveBranchIsRemote: null,
257 startFromOrigin: true,
258 }),
259 ).toBe("Select ref");
260 });
261
262 it("does not fabricate an origin ref while branch metadata is loading", () => {
263 expect(
264 resolveBranchTriggerLabel({
265 activeWorktreePath: null,
266 effectiveEnvMode: "worktree",
267 resolvedActiveBranch: "upstream/feature/demo",
268 resolvedActiveBranchIsRemote: null,
269 startFromOrigin: true,
270 }),
271 ).toBe("From upstream/feature/demo");
272 });
273});
274
275describe("resolveBranchToolbarPrBranch", () => {
276 it("uses the explicit thread branch when it matches the displayed branch", () => {
277 expect(
278 resolveBranchToolbarPrBranch({
279 activeThreadBranch: "feature/current",
280 resolvedActiveBranch: "feature/current",
281 }),
282 ).toBe("feature/current");
283 });
284
285 it("hides PR state while an optimistic branch switch is in flight", () => {
286 expect(
287 resolveBranchToolbarPrBranch({
288 activeThreadBranch: "feature/current",
289 resolvedActiveBranch: "feature/next",
290 }),
291 ).toBeNull();
292 });
293
294 it("does not infer PR state without an explicit thread branch", () => {
295 expect(
296 resolveBranchToolbarPrBranch({
297 activeThreadBranch: null,
298 resolvedActiveBranch: "feature/current",
299 }),
300 ).toBeNull();
301 });
302});
303
304describe("resolveLocalCheckoutBranchMismatch", () => {
305 it("detects when a local thread is associated with a different branch than the checkout", () => {
306 expect(
307 resolveLocalCheckoutBranchMismatch({
308 effectiveEnvMode: "local",
309 activeWorktreePath: null,
310 activeThreadBranch: "feature/thread",
311 currentGitBranch: "feature/current",
312 }),
313 ).toEqual({
314 threadBranch: "feature/thread",
315 currentBranch: "feature/current",
316 });
317 });
318
319 it("ignores matching local checkout state", () => {
320 expect(
321 resolveLocalCheckoutBranchMismatch({
322 effectiveEnvMode: "local",
323 activeWorktreePath: null,
324 activeThreadBranch: "feature/thread",
325 currentGitBranch: "feature/thread",
326 }),
327 ).toBeNull();
328 });
329
330 it("ignores dedicated worktrees because their checkout is already thread-scoped", () => {
331 expect(
332 resolveLocalCheckoutBranchMismatch({
333 effectiveEnvMode: "worktree",
334 activeWorktreePath: "/repo/.t3/worktrees/feature-thread",
335 activeThreadBranch: "feature/thread",
336 currentGitBranch: "feature/current",
337 }),
338 ).toBeNull();
339 });
340
341 it("ignores new-worktree base selection before a worktree exists", () => {
342 expect(
343 resolveLocalCheckoutBranchMismatch({
344 effectiveEnvMode: "worktree",
345 activeWorktreePath: null,
346 activeThreadBranch: "feature/base",
347 currentGitBranch: "main",
348 }),
349 ).toBeNull();
350 });
351});
352
353describe("resolveEnvironmentOptionLabel", () => {
354 it("prefers the primary environment's machine label", () => {
355 expect(
356 resolveEnvironmentOptionLabel({
357 isPrimary: true,
358 environmentId: localEnvironmentId,
359 runtimeLabel: "Julius's Mac mini",
360 savedLabel: "Local environment",
361 }),
362 ).toBe("Julius's Mac mini");
363 });
364
365 it("falls back to 'This device' for generic primary labels", () => {
366 expect(
367 resolveEnvironmentOptionLabel({
368 isPrimary: true,
369 environmentId: localEnvironmentId,
370 runtimeLabel: "Local environment",
371 savedLabel: "Local",
372 }),
373 ).toBe("This device");
374 });
375
376 it("keeps configured labels for non-primary environments", () => {
377 expect(
378 resolveEnvironmentOptionLabel({
379 isPrimary: false,
380 environmentId: remoteEnvironmentId,
381 runtimeLabel: null,
382 savedLabel: "Build box",
383 }),
384 ).toBe("Build box");
385 });
386});
387
388describe("shouldShowEnvironmentIndicator", () => {
389 it("shows the indicator whenever multiple environments are pickable", () => {
390 expect(
391 shouldShowEnvironmentIndicator({
392 activeEnvironment: { isPrimary: true },
393 canPickEnvironment: true,
394 }),
395 ).toBe(true);
396 });
397
398 it("shows a sole remote environment so the user knows where the project runs", () => {
399 expect(
400 shouldShowEnvironmentIndicator({
401 activeEnvironment: { isPrimary: false },
402 canPickEnvironment: false,
403 }),
404 ).toBe(true);
405 });
406
407 it("hides a sole primary (this-device) environment", () => {
408 expect(
409 shouldShowEnvironmentIndicator({
410 activeEnvironment: { isPrimary: true },
411 canPickEnvironment: false,
412 }),
413 ).toBe(false);
414 });
415
416 it("hides the indicator when the active environment is unknown", () => {
417 expect(
418 shouldShowEnvironmentIndicator({
419 activeEnvironment: null,
420 canPickEnvironment: false,
421 }),
422 ).toBe(false);
423 });
424});
425
426describe("shouldShowComposerContextStrip", () => {
427 it.each([false, true])(
428 "honors the active-thread preference with resting controls %s",
429 (hostsRestingComposerControls) => {
430 const input = {
431 isDraftHeroState: false,
432 hasActiveProject: true,
433 isGitRepo: true,
434 showEnvironmentIndicator: true,
435 hostsRestingComposerControls,
436 };
437 expect(shouldShowComposerContextStrip({ ...input, persistInActiveThreads: false })).toBe(
438 false,
439 );
440 expect(shouldShowComposerContextStrip({ ...input, persistInActiveThreads: true })).toBe(true);
441 },
442 );
443
444 it("keeps the environment indicator visible for a non-Git project", () => {
445 expect(
446 shouldShowComposerContextStrip({
447 isDraftHeroState: true,
448 persistInActiveThreads: false,
449 hasActiveProject: true,
450 isGitRepo: false,
451 showEnvironmentIndicator: true,
452 hostsRestingComposerControls: false,
453 }),
454 ).toBe(true);
455 });
456
457 it("hides the strip when a non-Git project has nothing to show", () => {
458 expect(
459 shouldShowComposerContextStrip({
460 isDraftHeroState: true,
461 persistInActiveThreads: false,
462 hasActiveProject: true,
463 isGitRepo: false,
464 showEnvironmentIndicator: false,
465 hostsRestingComposerControls: false,
466 }),
467 ).toBe(false);
468 });
469
470 it("keeps the strip for visible resting composer controls in a non-Git thread", () => {
471 expect(
472 shouldShowComposerContextStrip({
473 isDraftHeroState: true,
474 persistInActiveThreads: false,
475 hasActiveProject: true,
476 isGitRepo: false,
477 showEnvironmentIndicator: false,
478 hostsRestingComposerControls: true,
479 }),
480 ).toBe(true);
481 });
482
483 it("shows Git controls without requiring an environment indicator", () => {
484 expect(
485 shouldShowComposerContextStrip({
486 isDraftHeroState: true,
487 persistInActiveThreads: false,
488 hasActiveProject: true,
489 isGitRepo: true,
490 showEnvironmentIndicator: false,
491 hostsRestingComposerControls: false,
492 }),
493 ).toBe(true);
494 });
495});
496
497describe("resolveEffectiveEnvMode", () => {
498 it("treats draft threads already attached to a worktree as current-checkout mode", () => {
499 expect(
500 resolveEffectiveEnvMode({
501 activeWorktreePath: "/repo/.t3/worktrees/feature-a",
502 hasServerThread: false,
503 draftThreadEnvMode: "worktree",
504 }),
505 ).toBe("local");
506 });
507
508 it("keeps explicit new-worktree mode for draft threads without a worktree path", () => {
509 expect(
510 resolveEffectiveEnvMode({
511 activeWorktreePath: null,
512 hasServerThread: false,
513 draftThreadEnvMode: "worktree",
514 }),
515 ).toBe("worktree");
516 });
517
518 it("keeps a server thread in worktree mode while its worktree is still being created", () => {
519 expect(
520 resolveEffectiveEnvMode({
521 activeWorktreePath: null,
522 hasServerThread: true,
523 draftThreadEnvMode: undefined,
524 preparingWorktree: true,
525 }),
526 ).toBe("worktree");
527 expect(
528 resolveEffectiveEnvMode({
529 activeWorktreePath: null,
530 hasServerThread: true,
531 draftThreadEnvMode: undefined,
532 }),
533 ).toBe("local");
534 });
535});
536
537describe("resolveEnvModeLabel", () => {
538 it("uses explicit workspace labels", () => {
539 expect(resolveEnvModeLabel("local")).toBe("Current checkout");
540 expect(resolveEnvModeLabel("worktree")).toBe("New worktree");
541 });
542});
543
544describe("resolveCurrentWorkspaceLabel", () => {
545 it("describes the main repo checkout when no worktree path is active", () => {
546 expect(resolveCurrentWorkspaceLabel(null)).toBe("Current checkout");
547 });
548
549 it("describes the active checkout as a worktree when one is attached", () => {
550 expect(resolveCurrentWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Current worktree");
551 });
552});
553
554describe("resolveLockedWorkspaceLabel", () => {
555 it("uses a shorter label for the main repo checkout", () => {
556 expect(resolveLockedWorkspaceLabel(null, "local")).toBe("Local checkout");
557 });
558
559 it("uses a shorter label for an attached worktree", () => {
560 expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a", "worktree")).toBe(
561 "Worktree",
562 );
563 });
564
565 it("describes a worktree that is still being created as a new worktree", () => {
566 expect(resolveLockedWorkspaceLabel(null, "worktree")).toBe("New worktree");
567 });
568});
569
570describe("deriveLocalBranchNameFromRemoteRef", () => {
571 it("strips the remote prefix from a remote ref", () => {
572 expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo");
573 });
574
575 it("supports remote names that contain slashes", () => {
576 expect(deriveLocalBranchNameFromRemoteRef("my-org/upstream/feature/demo")).toBe(
577 "upstream/feature/demo",
578 );
579 });
580
581 it("returns the original name when ref is malformed", () => {
582 expect(deriveLocalBranchNameFromRemoteRef("origin/")).toBe("origin/");
583 expect(deriveLocalBranchNameFromRemoteRef("/feature/demo")).toBe("/feature/demo");
584 });
585});
586
587describe("dedupeRemoteBranchesWithLocalMatches", () => {
588 it("hides remote refs when the matching local ref exists", () => {
589 const input: VcsRef[] = [
590 {
591 name: "feature/demo",
592 current: false,
593 isDefault: false,
594 worktreePath: null,
595 },
596 {
597 name: "origin/feature/demo",
598 isRemote: true,
599 remoteName: "origin",
600 current: false,
601 isDefault: false,
602 worktreePath: null,
603 },
604 {
605 name: "origin/feature/remote-only",
606 isRemote: true,
607 remoteName: "origin",
608 current: false,
609 isDefault: false,
610 worktreePath: null,
611 },
612 ];
613
614 expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([
615 "feature/demo",
616 "origin/feature/remote-only",
617 ]);
618 });
619
620 it("keeps all entries when no local match exists for a remote ref", () => {
621 const input: VcsRef[] = [
622 {
623 name: "feature/local",
624 current: false,
625 isDefault: false,
626 worktreePath: null,
627 },
628 {
629 name: "origin/feature/remote-only",
630 isRemote: true,
631 remoteName: "origin",
632 current: false,
633 isDefault: false,
634 worktreePath: null,
635 },
636 ];
637
638 expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([
639 "feature/local",
640 "origin/feature/remote-only",
641 ]);
642 });
643
644 it("keeps non-origin remote refs visible even when a matching local ref exists", () => {
645 const input: VcsRef[] = [
646 {
647 name: "feature/demo",
648 current: false,
649 isDefault: false,
650 worktreePath: null,
651 },
652 {
653 name: "my-org/upstream/feature/demo",
654 isRemote: true,
655 remoteName: "my-org/upstream",
656 current: false,
657 isDefault: false,
658 worktreePath: null,
659 },
660 ];
661
662 expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([
663 "feature/demo",
664 "my-org/upstream/feature/demo",
665 ]);
666 });
667
668 it("keeps non-origin remote refs visible when git tracks with first-slash local naming", () => {
669 const input: VcsRef[] = [
670 {
671 name: "upstream/feature",
672 current: false,
673 isDefault: false,
674 worktreePath: null,
675 },
676 {
677 name: "my-org/upstream/feature",
678 isRemote: true,
679 remoteName: "my-org/upstream",
680 current: false,
681 isDefault: false,
682 worktreePath: null,
683 },
684 ];
685
686 expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([
687 "upstream/feature",
688 "my-org/upstream/feature",
689 ]);
690 });
691});
692
693describe("resolveBranchSelectionTarget", () => {
694 it("reuses an existing secondary worktree for the selected ref", () => {
695 expect(
696 resolveBranchSelectionTarget({
697 activeProjectCwd: "/repo",
698 activeWorktreePath: "/repo/.t3/worktrees/feature-a",
699 refName: {
700 isDefault: false,
701 worktreePath: "/repo/.t3/worktrees/feature-b",
702 },
703 }),
704 ).toEqual({
705 checkoutCwd: "/repo/.t3/worktrees/feature-b",
706 nextWorktreePath: "/repo/.t3/worktrees/feature-b",
707 reuseExistingWorktree: true,
708 });
709 });
710
711 it("switches back to the main repo when the ref already lives there", () => {
712 expect(
713 resolveBranchSelectionTarget({
714 activeProjectCwd: "/repo",
715 activeWorktreePath: "/repo/.t3/worktrees/feature-a",
716 refName: {
717 isDefault: true,
718 worktreePath: "/repo",
719 },
720 }),
721 ).toEqual({
722 checkoutCwd: "/repo",
723 nextWorktreePath: null,
724 reuseExistingWorktree: true,
725 });
726 });
727
728 it("checks out the default ref in the main repo when leaving a secondary worktree", () => {
729 expect(
730 resolveBranchSelectionTarget({
731 activeProjectCwd: "/repo",
732 activeWorktreePath: "/repo/.t3/worktrees/feature-a",
733 refName: {
734 isDefault: true,
735 worktreePath: null,
736 },
737 }),
738 ).toEqual({
739 checkoutCwd: "/repo",
740 nextWorktreePath: null,
741 reuseExistingWorktree: false,
742 });
743 });
744
745 it("keeps checkout in the current worktree for non-default refs", () => {
746 expect(
747 resolveBranchSelectionTarget({
748 activeProjectCwd: "/repo",
749 activeWorktreePath: "/repo/.t3/worktrees/feature-a",
750 refName: {
751 isDefault: false,
752 worktreePath: null,
753 },
754 }),
755 ).toEqual({
756 checkoutCwd: "/repo/.t3/worktrees/feature-a",
757 nextWorktreePath: "/repo/.t3/worktrees/feature-a",
758 reuseExistingWorktree: false,
759 });
760 });
761});
762
763describe("shouldIncludeBranchPickerItem", () => {
764 it("keeps the synthetic checkout PR item visible for gh pr checkout input", () => {
765 expect(
766 shouldIncludeBranchPickerItem({
767 itemValue: "__checkout_pull_request__:1359",
768 normalizedQuery: "gh pr checkout 1359",
769 createBranchItemValue: "__create_new_branch__:gh pr checkout 1359",
770 checkoutPullRequestItemValue: "__checkout_pull_request__:1359",
771 }),
772 ).toBe(true);
773 });
774
775 it("keeps the synthetic create-ref item visible for arbitrary ref input", () => {
776 expect(
777 shouldIncludeBranchPickerItem({
778 itemValue: "__create_new_branch__:feature/demo",
779 normalizedQuery: "feature/demo",
780 createBranchItemValue: "__create_new_branch__:feature/demo",
781 checkoutPullRequestItemValue: null,
782 }),
783 ).toBe(true);
784 });
785
786 it("still filters ordinary ref items by query text", () => {
787 expect(
788 shouldIncludeBranchPickerItem({
789 itemValue: "main",
790 normalizedQuery: "gh pr checkout 1359",
791 createBranchItemValue: "__create_new_branch__:gh pr checkout 1359",
792 checkoutPullRequestItemValue: "__checkout_pull_request__:1359",
793 }),
794 ).toBe(false);
795 });
796
797 // Typing a spaced name must still surface the ref it would have been created
798 // as, or the picker shows nothing at all for that query.
799 it("surfaces an existing ref matching the sanitized query", () => {
800 expect(
801 shouldIncludeBranchPickerItem({
802 itemValue: "new-branch",
803 normalizedQuery: "new branch",
804 createBranchItemValue: null,
805 checkoutPullRequestItemValue: null,
806 }),
807 ).toBe(true);
808 });
809
810 // A partial query has to reach the ref it would have been created as, so
811 // searching "hello w" still finds an existing hello-world.
812 it("surfaces a ref from a partial query containing a space", () => {
813 expect(
814 shouldIncludeBranchPickerItem({
815 itemValue: "hello-world",
816 normalizedQuery: "hello w",
817 createBranchItemValue: null,
818 checkoutPullRequestItemValue: null,
819 }),
820 ).toBe(true);
821 });
822
823 it("excludes refs matching neither the raw nor the sanitized query", () => {
824 expect(
825 shouldIncludeBranchPickerItem({
826 itemValue: "main",
827 normalizedQuery: "new branch",
828 createBranchItemValue: null,
829 checkoutPullRequestItemValue: null,
830 }),
831 ).toBe(false);
832 });
833});
834
835// Git rejects ASCII space and the ASCII control characters in ref names, so a
836// typed name like "new branch" can only ever fail. Replacing exactly those can
837// turn a failing name into a working one without touching a name git already
838// accepts, including one holding non-ASCII whitespace such as U+00A0.
839describe("sanitizeNewRefName", () => {
840 it("replaces a space with a dash", () => {
841 expect(sanitizeNewRefName("new branch")).toBe("new-branch");
842 });
843
844 it("collapses a run of whitespace into a single dash", () => {
845 expect(sanitizeNewRefName("new branch")).toBe("new-branch");
846 });
847
848 it("trims surrounding whitespace instead of turning it into dashes", () => {
849 expect(sanitizeNewRefName(" new branch ")).toBe("new-branch");
850 });
851
852 it("replaces tabs, which git rejects just like spaces", () => {
853 expect(sanitizeNewRefName("new\tbranch")).toBe("new-branch");
854 });
855
856 // git accepts U+00A0, U+2009 and other non-ASCII whitespace in ref names, so
857 // rewriting them would silently create a ref the user never typed.
858 it("preserves whitespace that git accepts", () => {
859 expect(sanitizeNewRefName("new\u00a0branch")).toBe("new\u00a0branch");
860 expect(sanitizeNewRefName("new\u2009branch")).toBe("new\u2009branch");
861 });
862
863 it("keeps slashes so nested ref names survive", () => {
864 expect(sanitizeNewRefName("feature/new thing")).toBe("feature/new-thing");
865 });
866
867 it("preserves case because git ref names are case sensitive", () => {
868 expect(sanitizeNewRefName("Feature/New Thing")).toBe("Feature/New-Thing");
869 });
870
871 it("leaves an already valid ref name untouched", () => {
872 expect(sanitizeNewRefName("feature/login")).toBe("feature/login");
873 });
874
875 it("returns an empty string for whitespace-only input", () => {
876 expect(sanitizeNewRefName(" ")).toBe("");
877 });
878
879 // Scoped deliberately to whitespace: git accepts consecutive dashes, so
880 // collapsing them would rewrite names the user may have typed on purpose.
881 it("does not collapse dashes the user typed", () => {
882 expect(sanitizeNewRefName("new - branch")).toBe("new---branch");
883 expect(sanitizeNewRefName("foo--bar")).toBe("foo--bar");
884 });
885});