1import { SourceControlProviderKind } from "@t3tools/contracts";
2import type { VcsStatusResult } from "@t3tools/contracts";
3import { assert, describe, it } from "vite-plus/test";
4import {
5 buildGitActionProgressStages,
6 buildMenuItems,
7 formatGitActionElapsed,
8 requiresDefaultBranchConfirmation,
9 resolveAutoFeatureBranchName,
10 resolveDefaultBranchActionDialogCopy,
11 resolveGitActionProgressPresentation,
12 resolveGitActionResultToastTiming,
13 resolveLiveThreadBranchUpdate,
14 resolveQuickAction,
15 resolveThreadBranchUpdate,
16 resolveThreadBranchMetadataPatch,
17} from "./GitActionsControl.logic";
18
19function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
20 return {
21 isRepo: true,
22 hasPrimaryRemote: true,
23 isDefaultRef: false,
24 refName: "feature/test",
25 hasWorkingTreeChanges: false,
26 workingTree: {
27 files: [],
28 insertions: 0,
29 deletions: 0,
30 },
31 hasUpstream: true,
32 aheadCount: 0,
33 behindCount: 0,
34 pr: null,
35 ...overrides,
36 };
37}
38
39describe("git action progress presentation", () => {
40 it("keeps the phase on the first row and hook output on the second", () => {
41 assert.deepEqual(
42 resolveGitActionProgressPresentation({
43 isRunning: true,
44 operation: "run_change_request",
45 currentLabel: "Running pre-commit...",
46 lastOutputLine: "Checking formatting and lint rules",
47 phaseStartedAtMs: 1_000,
48 hookStartedAtMs: 2_000,
49 }),
50 {
51 status: "Running pre-commit...",
52 output: "Checking formatting and lint rules",
53 startedAtMs: 2_000,
54 },
55 );
56 });
57
58 it("omits empty output and supplies a brief startup label", () => {
59 assert.deepEqual(
60 resolveGitActionProgressPresentation({
61 isRunning: true,
62 operation: "run_change_request",
63 currentLabel: "Running source control action",
64 lastOutputLine: " ",
65 phaseStartedAtMs: 1_000,
66 hookStartedAtMs: null,
67 }),
68 {
69 status: "Starting source control action...",
70 output: null,
71 startedAtMs: 1_000,
72 },
73 );
74 });
75
76 it("presents pull progress inline without a second row", () => {
77 assert.deepEqual(
78 resolveGitActionProgressPresentation({
79 isRunning: true,
80 operation: "pull",
81 currentLabel: "Pulling latest changes...",
82 lastOutputLine: null,
83 phaseStartedAtMs: 1_000,
84 hookStartedAtMs: null,
85 }),
86 {
87 status: "Pulling latest changes...",
88 output: null,
89 startedAtMs: 1_000,
90 },
91 );
92 });
93
94 it("does not present unrelated or completed actions", () => {
95 const input = {
96 isRunning: true,
97 operation: "publish_repository",
98 currentLabel: "Publishing repository",
99 lastOutputLine: null,
100 phaseStartedAtMs: 1_000,
101 hookStartedAtMs: null,
102 };
103
104 assert.isNull(resolveGitActionProgressPresentation(input));
105 assert.isNull(resolveGitActionProgressPresentation({ ...input, isRunning: false }));
106 });
107
108 it("formats compact elapsed time without allowing negative values", () => {
109 assert.isNull(formatGitActionElapsed(null, 10_000));
110 assert.equal(formatGitActionElapsed(10_000, 9_000), "0s");
111 assert.equal(formatGitActionElapsed(10_000, 14_900), "4s");
112 assert.equal(formatGitActionElapsed(10_000, 75_000), "1m 5s");
113 });
114});
115
116describe("git action result toast timing", () => {
117 it("keeps errors sticky and dismisses successes after visible time", () => {
118 assert.deepEqual(resolveGitActionResultToastTiming("error"), {
119 timeout: 0,
120 dismissAfterVisibleMs: null,
121 });
122 assert.deepEqual(resolveGitActionResultToastTiming("success"), {
123 timeout: 0,
124 dismissAfterVisibleMs: 10_000,
125 });
126 });
127});
128
129describe("when: ref is clean and has an open PR", () => {
130 it("resolveQuickAction rests in the disabled up-to-date state", () => {
131 const quick = resolveQuickAction(
132 status({
133 pr: {
134 number: 10,
135 title: "Open PR",
136 url: "https://example.com/pr/10",
137 baseRef: "main",
138 headRef: "feature/test",
139 state: "open",
140 },
141 }),
142 false,
143 );
144 assert.deepInclude(quick, {
145 kind: "show_hint",
146 label: "Commit",
147 disabled: true,
148 hint: "Branch is up to date. No action needed.",
149 });
150 });
151
152 it("buildMenuItems disables commit/push and omits the PR entry", () => {
153 const items = buildMenuItems(
154 status({
155 pr: {
156 number: 11,
157 title: "Existing PR",
158 url: "https://example.com/pr/11",
159 baseRef: "main",
160 headRef: "feature/test",
161 state: "open",
162 },
163 }),
164 false,
165 );
166 assert.deepEqual(items, [
167 {
168 id: "commit",
169 label: "Commit",
170 disabled: true,
171 icon: "commit",
172 kind: "open_dialog",
173 dialogAction: "commit",
174 },
175 {
176 id: "push",
177 label: "Push",
178 disabled: true,
179 icon: "push",
180 kind: "open_dialog",
181 dialogAction: "push",
182 },
183 ]);
184 });
185});
186
187describe("when: actions are busy", () => {
188 it("resolveQuickAction returns running disabled state", () => {
189 const quick = resolveQuickAction(status(), true);
190 assert.deepInclude(quick, {
191 kind: "show_hint",
192 label: "Commit",
193 disabled: true,
194 hint: "Git action in progress.",
195 });
196 });
197
198 it("buildMenuItems disables all actions", () => {
199 const items = buildMenuItems(status(), true);
200 assert.deepEqual(items, [
201 {
202 id: "commit",
203 label: "Commit",
204 disabled: true,
205 icon: "commit",
206 kind: "open_dialog",
207 dialogAction: "commit",
208 },
209 {
210 id: "push",
211 label: "Push",
212 disabled: true,
213 icon: "push",
214 kind: "open_dialog",
215 dialogAction: "push",
216 },
217 {
218 id: "pr",
219 label: "Create PR",
220 disabled: true,
221 icon: "pr",
222 kind: "open_dialog",
223 dialogAction: "create_pr",
224 },
225 ]);
226 });
227});
228
229describe("when: git status is unavailable", () => {
230 it("resolveQuickAction returns unavailable disabled state", () => {
231 const quick = resolveQuickAction(null, false);
232 assert.deepInclude(quick, {
233 kind: "show_hint",
234 label: "Commit",
235 disabled: true,
236 hint: "Git status is unavailable.",
237 });
238 });
239
240 it("buildMenuItems returns no menu items", () => {
241 const items = buildMenuItems(null, false);
242 assert.deepEqual(items, []);
243 });
244});
245
246describe("when: ref is clean, ahead, and has an open PR", () => {
247 it("resolveQuickAction prefers push", () => {
248 const quick = resolveQuickAction(
249 status({
250 aheadCount: 3,
251 pr: {
252 number: 13,
253 title: "Open PR",
254 url: "https://example.com/pr/13",
255 baseRef: "main",
256 headRef: "feature/test",
257 state: "open",
258 },
259 }),
260 false,
261 );
262 assert.deepInclude(quick, { kind: "run_action", action: "push", label: "Push" });
263 });
264
265 it("buildMenuItems enables push and omits the PR entry", () => {
266 const items = buildMenuItems(
267 status({
268 aheadCount: 2,
269 pr: {
270 number: 12,
271 title: "Existing PR",
272 url: "https://example.com/pr/12",
273 baseRef: "main",
274 headRef: "feature/test",
275 state: "open",
276 },
277 }),
278 false,
279 );
280 assert.deepEqual(items, [
281 {
282 id: "commit",
283 label: "Commit",
284 disabled: true,
285 icon: "commit",
286 kind: "open_dialog",
287 dialogAction: "commit",
288 },
289 {
290 id: "push",
291 label: "Push",
292 disabled: false,
293 icon: "push",
294 kind: "open_dialog",
295 dialogAction: "push",
296 },
297 ]);
298 });
299});
300
301describe("when: ref is clean, ahead, and has no open PR", () => {
302 it("resolveQuickAction pushes and creates a PR", () => {
303 const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false);
304 assert.deepInclude(quick, {
305 kind: "run_action",
306 action: "create_pr",
307 label: "Push & create PR",
308 });
309 });
310
311 it("buildMenuItems enables push and create PR, with commit disabled", () => {
312 const items = buildMenuItems(status({ aheadCount: 2, pr: null }), false);
313 assert.deepEqual(items, [
314 {
315 id: "commit",
316 label: "Commit",
317 disabled: true,
318 icon: "commit",
319 kind: "open_dialog",
320 dialogAction: "commit",
321 },
322 {
323 id: "push",
324 label: "Push",
325 disabled: false,
326 icon: "push",
327 kind: "open_dialog",
328 dialogAction: "push",
329 },
330 {
331 id: "pr",
332 label: "Create PR",
333 disabled: false,
334 icon: "pr",
335 kind: "open_dialog",
336 dialogAction: "create_pr",
337 },
338 ]);
339 });
340});
341
342describe("when: source control provider uses merge requests", () => {
343 it("uses GitLab MR terminology in quick actions and menu items", () => {
344 const gitlabStatus = status({
345 aheadCount: 2,
346 sourceControlProvider: {
347 kind: SourceControlProviderKind.make("gitlab"),
348 name: "GitLab",
349 baseUrl: "https://gitlab.com",
350 },
351 });
352
353 const quick = resolveQuickAction(gitlabStatus, false);
354 const items = buildMenuItems(gitlabStatus, false);
355
356 assert.deepInclude(quick, {
357 kind: "run_action",
358 action: "create_pr",
359 label: "Push & create MR",
360 });
361 assert.deepInclude(items[2], {
362 id: "pr",
363 label: "Create MR",
364 });
365 });
366});
367
368describe("when: ref is clean, up to date, and has no open PR", () => {
369 it("enables create PR when synced with upstream but ahead of default, even with local changes", () => {
370 const syncedFeature = status({
371 aheadCount: 0,
372 behindCount: 0,
373 aheadOfDefaultCount: 1,
374 pr: null,
375 });
376
377 const quick = resolveQuickAction(syncedFeature, false);
378 assert.deepInclude(quick, {
379 label: "Create PR",
380 disabled: false,
381 kind: "run_action",
382 action: "create_pr",
383 });
384
385 const items = buildMenuItems(syncedFeature, false);
386 assert.equal(items.find((item) => item.id === "pr")?.disabled, false);
387
388 const dirtyFeature = { ...syncedFeature, hasWorkingTreeChanges: true };
389 assert.equal(
390 buildMenuItems(dirtyFeature, false).find((item) => item.id === "pr")?.disabled,
391 false,
392 );
393 });
394
395 it("resolveQuickAction returns disabled no-action state", () => {
396 const quick = resolveQuickAction(
397 status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }),
398 false,
399 );
400 assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true });
401 });
402
403 it("buildMenuItems disables commit, push, and create PR", () => {
404 const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, pr: null }), false);
405 assert.deepEqual(items, [
406 {
407 id: "commit",
408 label: "Commit",
409 disabled: true,
410 icon: "commit",
411 kind: "open_dialog",
412 dialogAction: "commit",
413 },
414 {
415 id: "push",
416 label: "Push",
417 disabled: true,
418 icon: "push",
419 kind: "open_dialog",
420 dialogAction: "push",
421 },
422 {
423 id: "pr",
424 label: "Create PR",
425 disabled: true,
426 icon: "pr",
427 kind: "open_dialog",
428 dialogAction: "create_pr",
429 },
430 ]);
431 });
432});
433
434describe("when: ref is behind upstream", () => {
435 it("resolveQuickAction returns pull", () => {
436 const quick = resolveQuickAction(status({ behindCount: 2 }), false);
437 assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false });
438 });
439
440 it("buildMenuItems disables push and create PR", () => {
441 const items = buildMenuItems(status({ behindCount: 1, pr: null }), false);
442 assert.deepEqual(items, [
443 {
444 id: "commit",
445 label: "Commit",
446 disabled: true,
447 icon: "commit",
448 kind: "open_dialog",
449 dialogAction: "commit",
450 },
451 {
452 id: "push",
453 label: "Push",
454 disabled: true,
455 icon: "push",
456 kind: "open_dialog",
457 dialogAction: "push",
458 },
459 {
460 id: "pr",
461 label: "Create PR",
462 disabled: true,
463 icon: "pr",
464 kind: "open_dialog",
465 dialogAction: "create_pr",
466 },
467 ]);
468 });
469});
470
471describe("when: ref has diverged from upstream", () => {
472 it("resolveQuickAction returns a disabled sync hint", () => {
473 const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false);
474 assert.deepEqual(quick, {
475 label: "Sync ref",
476 disabled: true,
477 kind: "show_hint",
478 hint: "Branch has diverged from upstream. Rebase/merge first.",
479 });
480 });
481});
482
483describe("when: working tree has local changes", () => {
484 it("resolveQuickAction returns commit, push, and create PR", () => {
485 const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false);
486 assert.deepInclude(quick, {
487 kind: "run_action",
488 action: "commit_push_pr",
489 label: "Commit, push & PR",
490 });
491 });
492
493 it("resolveQuickAction falls back to commit when no origin remote exists", () => {
494 const quick = resolveQuickAction(
495 status({ hasWorkingTreeChanges: true, hasUpstream: false }),
496 false,
497 false,
498 false,
499 );
500 assert.deepInclude(quick, {
501 kind: "run_action",
502 action: "commit",
503 label: "Commit",
504 disabled: false,
505 });
506 });
507
508 it("resolveQuickAction returns commit and push when open PR exists", () => {
509 const quick = resolveQuickAction(
510 status({
511 hasWorkingTreeChanges: true,
512 pr: {
513 number: 16,
514 title: "Existing PR",
515 url: "https://example.com/pr/16",
516 baseRef: "main",
517 headRef: "feature/test",
518 state: "open",
519 },
520 }),
521 false,
522 );
523 assert.deepInclude(quick, {
524 kind: "run_action",
525 action: "commit_push",
526 label: "Commit & push",
527 });
528 });
529
530 it("buildMenuItems enables commit and disables push and PR", () => {
531 const items = buildMenuItems(status({ hasWorkingTreeChanges: true }), false);
532 assert.deepEqual(items, [
533 {
534 id: "commit",
535 label: "Commit",
536 disabled: false,
537 icon: "commit",
538 kind: "open_dialog",
539 dialogAction: "commit",
540 },
541 {
542 id: "push",
543 label: "Push",
544 disabled: true,
545 icon: "push",
546 kind: "open_dialog",
547 dialogAction: "push",
548 },
549 {
550 id: "pr",
551 label: "Create PR",
552 disabled: true,
553 icon: "pr",
554 kind: "open_dialog",
555 dialogAction: "create_pr",
556 },
557 ]);
558 });
559
560 it("buildMenuItems enables push and create PR for ahead commits while local changes remain uncommitted", () => {
561 const items = buildMenuItems(
562 status({
563 refName: "feature/test",
564 hasWorkingTreeChanges: true,
565 aheadCount: 1,
566 workingTree: {
567 files: [{ path: ".vercel/project.json", insertions: 1, deletions: 0 }],
568 insertions: 1,
569 deletions: 0,
570 },
571 }),
572 false,
573 );
574 assert.deepEqual(items, [
575 {
576 id: "commit",
577 label: "Commit",
578 disabled: false,
579 icon: "commit",
580 kind: "open_dialog",
581 dialogAction: "commit",
582 },
583 {
584 id: "push",
585 label: "Push",
586 disabled: false,
587 icon: "push",
588 kind: "open_dialog",
589 dialogAction: "push",
590 },
591 {
592 id: "pr",
593 label: "Create PR",
594 disabled: false,
595 icon: "pr",
596 kind: "open_dialog",
597 dialogAction: "create_pr",
598 },
599 ]);
600 });
601});
602
603describe("when: on default ref without open PR", () => {
604 it("resolveQuickAction returns commit and push when local changes exist", () => {
605 const quick = resolveQuickAction(
606 status({ refName: "main", hasWorkingTreeChanges: true }),
607 false,
608 true,
609 );
610 assert.deepInclude(quick, {
611 kind: "run_action",
612 action: "commit_push",
613 label: "Commit & push",
614 disabled: false,
615 });
616 });
617
618 it("resolveQuickAction returns push when ref is ahead", () => {
619 const quick = resolveQuickAction(
620 status({ refName: "main", aheadCount: 2, pr: null }),
621 false,
622 true,
623 );
624 assert.deepInclude(quick, {
625 kind: "run_action",
626 action: "commit_push",
627 label: "Push",
628 disabled: false,
629 });
630 });
631});
632
633describe("when: working tree has local changes and ref is behind upstream", () => {
634 it("resolveQuickAction still prefers commit, push, and create PR", () => {
635 const quick = resolveQuickAction(
636 status({ hasWorkingTreeChanges: true, behindCount: 1 }),
637 false,
638 );
639 assert.deepInclude(quick, {
640 kind: "run_action",
641 action: "commit_push_pr",
642 label: "Commit, push & PR",
643 });
644 });
645
646 it("buildMenuItems enables commit and keeps push and PR disabled", () => {
647 const items = buildMenuItems(status({ hasWorkingTreeChanges: true, behindCount: 2 }), false);
648 assert.deepEqual(items, [
649 {
650 id: "commit",
651 label: "Commit",
652 disabled: false,
653 icon: "commit",
654 kind: "open_dialog",
655 dialogAction: "commit",
656 },
657 {
658 id: "push",
659 label: "Push",
660 disabled: true,
661 icon: "push",
662 kind: "open_dialog",
663 dialogAction: "push",
664 },
665 {
666 id: "pr",
667 label: "Create PR",
668 disabled: true,
669 icon: "pr",
670 kind: "open_dialog",
671 dialogAction: "create_pr",
672 },
673 ]);
674 });
675});
676
677describe("when: HEAD is detached and there are no local changes", () => {
678 it("resolveQuickAction shows detached head hint", () => {
679 const quick = resolveQuickAction(
680 status({ refName: null, hasWorkingTreeChanges: false, hasUpstream: false }),
681 false,
682 );
683 assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true });
684 });
685
686 it("buildMenuItems keeps commit, push, and PR disabled", () => {
687 const items = buildMenuItems(status({ refName: null, hasWorkingTreeChanges: false }), false);
688 assert.deepEqual(items, [
689 {
690 id: "commit",
691 label: "Commit",
692 disabled: true,
693 icon: "commit",
694 kind: "open_dialog",
695 dialogAction: "commit",
696 },
697 {
698 id: "push",
699 label: "Push",
700 disabled: true,
701 icon: "push",
702 kind: "open_dialog",
703 dialogAction: "push",
704 },
705 {
706 id: "pr",
707 label: "Create PR",
708 disabled: true,
709 icon: "pr",
710 kind: "open_dialog",
711 dialogAction: "create_pr",
712 },
713 ]);
714 });
715});
716
717describe("when: ref has no upstream configured", () => {
718 it("resolveQuickAction is disabled when clean, no upstream, and no local commits are ahead", () => {
719 const quick = resolveQuickAction(
720 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
721 false,
722 );
723 assert.deepInclude(quick, {
724 kind: "show_hint",
725 label: "Push",
726 hint: "No local commits to push.",
727 disabled: true,
728 });
729 });
730
731 it("resolveQuickAction rests disabled when clean, no upstream, no local commits are ahead, and PR exists", () => {
732 const quick = resolveQuickAction(
733 status({
734 hasUpstream: false,
735 aheadCount: 0,
736 pr: {
737 number: 14,
738 title: "Existing PR",
739 url: "https://example.com/pr/14",
740 baseRef: "main",
741 headRef: "feature/test",
742 state: "open",
743 },
744 }),
745 false,
746 );
747 assert.deepInclude(quick, {
748 kind: "show_hint",
749 label: "Commit",
750 disabled: true,
751 hint: "Branch is up to date. No action needed.",
752 });
753 });
754
755 it("resolveQuickAction runs push when clean, no upstream, and local commits are ahead", () => {
756 const quick = resolveQuickAction(
757 status({
758 hasUpstream: false,
759 aheadCount: 1,
760 pr: {
761 number: 15,
762 title: "Existing PR",
763 url: "https://example.com/pr/15",
764 baseRef: "main",
765 headRef: "feature/test",
766 state: "open",
767 },
768 }),
769 false,
770 );
771 assert.deepInclude(quick, {
772 kind: "run_action",
773 action: "push",
774 label: "Push",
775 disabled: false,
776 });
777 });
778
779 it("buildMenuItems disables push and create PR when no commits are ahead", () => {
780 const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 0 }), false);
781 assert.deepEqual(items, [
782 {
783 id: "commit",
784 label: "Commit",
785 disabled: true,
786 icon: "commit",
787 kind: "open_dialog",
788 dialogAction: "commit",
789 },
790 {
791 id: "push",
792 label: "Push",
793 disabled: true,
794 icon: "push",
795 kind: "open_dialog",
796 dialogAction: "push",
797 },
798 {
799 id: "pr",
800 label: "Create PR",
801 disabled: true,
802 icon: "pr",
803 kind: "open_dialog",
804 dialogAction: "create_pr",
805 },
806 ]);
807 });
808
809 it("resolveQuickAction runs push and create PR when no upstream and commits are ahead", () => {
810 const quick = resolveQuickAction(
811 status({
812 hasUpstream: false,
813 aheadCount: 2,
814 pr: null,
815 }),
816 false,
817 );
818 assert.deepInclude(quick, {
819 kind: "run_action",
820 action: "create_pr",
821 label: "Push & create PR",
822 disabled: false,
823 });
824 });
825
826 it("resolveQuickAction rests without offering repository creation when no remote exists", () => {
827 const quick = resolveQuickAction(
828 status({
829 hasUpstream: false,
830 aheadCount: 2,
831 pr: null,
832 }),
833 false,
834 false,
835 false,
836 );
837 assert.deepEqual(quick, {
838 kind: "show_hint",
839 label: "Commit",
840 disabled: true,
841 hint: "No changes to commit.",
842 });
843 });
844
845 it("buildMenuItems enables create PR when no upstream and commits are ahead", () => {
846 const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 2 }), false);
847 assert.deepEqual(items, [
848 {
849 id: "commit",
850 label: "Commit",
851 disabled: true,
852 icon: "commit",
853 kind: "open_dialog",
854 dialogAction: "commit",
855 },
856 {
857 id: "push",
858 label: "Push",
859 disabled: false,
860 icon: "push",
861 kind: "open_dialog",
862 dialogAction: "push",
863 },
864 {
865 id: "pr",
866 label: "Create PR",
867 disabled: false,
868 icon: "pr",
869 kind: "open_dialog",
870 dialogAction: "create_pr",
871 },
872 ]);
873 });
874
875 it("buildMenuItems hides push and create PR when no origin remote exists", () => {
876 const items = buildMenuItems(
877 status({ hasUpstream: false, pr: null, aheadCount: 2 }),
878 false,
879 false,
880 );
881 assert.deepEqual(items, [
882 {
883 id: "commit",
884 label: "Commit",
885 disabled: true,
886 icon: "commit",
887 kind: "open_dialog",
888 dialogAction: "commit",
889 },
890 ]);
891 });
892
893 it("resolveQuickAction is disabled on default ref when no upstream exists and no commits are ahead", () => {
894 const quick = resolveQuickAction(
895 status({
896 refName: "main",
897 hasUpstream: false,
898 aheadCount: 0,
899 pr: null,
900 }),
901 false,
902 true,
903 );
904 assert.deepInclude(quick, {
905 kind: "show_hint",
906 label: "Push",
907 hint: "No local commits to push.",
908 disabled: true,
909 });
910 });
911
912 it("resolveQuickAction uses push-only on default ref when no upstream exists and commits are ahead", () => {
913 const quick = resolveQuickAction(
914 status({
915 refName: "main",
916 hasUpstream: false,
917 aheadCount: 1,
918 pr: null,
919 }),
920 false,
921 true,
922 );
923 assert.deepInclude(quick, {
924 kind: "run_action",
925 action: "commit_push",
926 label: "Push",
927 disabled: false,
928 });
929 });
930
931 it("buildMenuItems still disables push and create PR when ref is behind", () => {
932 const items = buildMenuItems(
933 status({
934 hasUpstream: false,
935 behindCount: 1,
936 aheadCount: 0,
937 pr: null,
938 }),
939 false,
940 );
941 assert.deepEqual(items, [
942 {
943 id: "commit",
944 label: "Commit",
945 disabled: true,
946 icon: "commit",
947 kind: "open_dialog",
948 dialogAction: "commit",
949 },
950 {
951 id: "push",
952 label: "Push",
953 disabled: true,
954 icon: "push",
955 kind: "open_dialog",
956 dialogAction: "push",
957 },
958 {
959 id: "pr",
960 label: "Create PR",
961 disabled: true,
962 icon: "pr",
963 kind: "open_dialog",
964 dialogAction: "create_pr",
965 },
966 ]);
967 });
968});
969
970describe("requiresDefaultBranchConfirmation", () => {
971 it("requires confirmation for push actions on default ref", () => {
972 assert.isFalse(requiresDefaultBranchConfirmation("commit", true));
973 assert.isTrue(requiresDefaultBranchConfirmation("push", true));
974 assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true));
975 assert.isTrue(requiresDefaultBranchConfirmation("commit_push", true));
976 assert.isTrue(requiresDefaultBranchConfirmation("commit_push_pr", true));
977 assert.isFalse(requiresDefaultBranchConfirmation("commit_push", false));
978 assert.isFalse(requiresDefaultBranchConfirmation("push", false));
979 });
980});
981
982describe("resolveDefaultBranchActionDialogCopy", () => {
983 it("uses push-only copy when pushing without a commit", () => {
984 const copy = resolveDefaultBranchActionDialogCopy({
985 action: "commit_push",
986 branchName: "main",
987 includesCommit: false,
988 });
989
990 assert.deepEqual(copy, {
991 title: "Push to default ref?",
992 description:
993 'This action will push local commits on "main". You can continue on this ref or create a feature ref and run the same action there.',
994 continueLabel: "Push to main",
995 });
996 });
997
998 it("uses push-and-pr copy when creating a PR without a commit", () => {
999 const copy = resolveDefaultBranchActionDialogCopy({
1000 action: "commit_push_pr",
1001 branchName: "main",
1002 includesCommit: false,
1003 });
1004
1005 assert.deepEqual(copy, {
1006 title: "Push & create PR from default ref?",
1007 description:
1008 'This action will push local commits and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.',
1009 continueLabel: "Push & create PR",
1010 });
1011 });
1012
1013 it("keeps commit copy when the action includes a commit", () => {
1014 const copy = resolveDefaultBranchActionDialogCopy({
1015 action: "commit_push_pr",
1016 branchName: "main",
1017 includesCommit: true,
1018 });
1019
1020 assert.deepEqual(copy, {
1021 title: "Commit, push & create PR from default ref?",
1022 description:
1023 'This action will commit, push, and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.',
1024 continueLabel: "Commit, push & create PR",
1025 });
1026 });
1027});
1028
1029describe("buildGitActionProgressStages", () => {
1030 it("shows only push progress for explicit push actions", () => {
1031 const stages = buildGitActionProgressStages({
1032 action: "push",
1033 hasCustomCommitMessage: false,
1034 hasWorkingTreeChanges: false,
1035 pushTarget: "origin/feature/test",
1036 });
1037 assert.deepEqual(stages, ["Pushing to origin/feature/test..."]);
1038 });
1039
1040 it("shows push and PR progress for create-pr actions that still need a push", () => {
1041 const stages = buildGitActionProgressStages({
1042 action: "create_pr",
1043 hasCustomCommitMessage: false,
1044 hasWorkingTreeChanges: false,
1045 pushTarget: "origin/feature/test",
1046 shouldPushBeforePr: true,
1047 });
1048 assert.deepEqual(stages, [
1049 "Pushing to origin/feature/test...",
1050 "Preparing PR...",
1051 "Generating PR content...",
1052 "Creating pull request...",
1053 ]);
1054 });
1055
1056 it("shows only PR progress when create-pr can skip the push", () => {
1057 const stages = buildGitActionProgressStages({
1058 action: "create_pr",
1059 hasCustomCommitMessage: false,
1060 hasWorkingTreeChanges: false,
1061 shouldPushBeforePr: false,
1062 });
1063 assert.deepEqual(stages, [
1064 "Preparing PR...",
1065 "Generating PR content...",
1066 "Creating pull request...",
1067 ]);
1068 });
1069
1070 it("includes commit stages for commit+push when working tree is dirty", () => {
1071 const stages = buildGitActionProgressStages({
1072 action: "commit_push",
1073 hasCustomCommitMessage: false,
1074 hasWorkingTreeChanges: true,
1075 pushTarget: "origin/feature/test",
1076 });
1077 assert.deepEqual(stages, [
1078 "Generating commit message...",
1079 "Committing...",
1080 "Pushing to origin/feature/test...",
1081 ]);
1082 });
1083
1084 it("includes granular PR stages for commit+push+PR actions", () => {
1085 const stages = buildGitActionProgressStages({
1086 action: "commit_push_pr",
1087 hasCustomCommitMessage: true,
1088 hasWorkingTreeChanges: true,
1089 pushTarget: "origin/feature/test",
1090 });
1091 assert.deepEqual(stages, [
1092 "Committing...",
1093 "Pushing to origin/feature/test...",
1094 "Preparing PR...",
1095 "Generating PR content...",
1096 "Creating pull request...",
1097 ]);
1098 });
1099});
1100
1101describe("resolveThreadBranchUpdate", () => {
1102 it("returns a branch update when the action created a new branch", () => {
1103 const update = resolveThreadBranchUpdate({
1104 action: "commit_push_pr",
1105 branch: {
1106 status: "created",
1107 name: "feature/fix-toast-copy",
1108 },
1109 commit: {
1110 status: "created",
1111 commitSha: "89abcdef01234567",
1112 subject: "feat: add ref sync",
1113 },
1114 push: { status: "pushed", branch: "feature/fix-toast-copy" },
1115 pr: { status: "skipped_not_requested" },
1116 toast: {
1117 title: "Pushed 89abcde to origin/feature/fix-toast-copy",
1118 cta: { kind: "none" },
1119 },
1120 });
1121
1122 assert.deepEqual(update, {
1123 branch: "feature/fix-toast-copy",
1124 });
1125 });
1126
1127 it("returns null when the action stayed on the existing branch", () => {
1128 const update = resolveThreadBranchUpdate({
1129 action: "commit_push",
1130 branch: {
1131 status: "skipped_not_requested",
1132 },
1133 commit: {
1134 status: "created",
1135 commitSha: "89abcdef01234567",
1136 subject: "feat: add ref sync",
1137 },
1138 push: { status: "pushed", branch: "feature/fix-toast-copy" },
1139 pr: { status: "skipped_not_requested" },
1140 toast: {
1141 title: "Pushed 89abcde to origin/feature/fix-toast-copy",
1142 cta: { kind: "none" },
1143 },
1144 });
1145
1146 assert.equal(update, null);
1147 });
1148});
1149
1150describe("resolveLiveThreadBranchUpdate", () => {
1151 it("returns a branch update when live git status differs from stored thread metadata", () => {
1152 const update = resolveLiveThreadBranchUpdate({
1153 threadBranch: "feature/old-ref",
1154 gitStatus: status({ refName: "effect-atom" }),
1155 });
1156
1157 assert.deepEqual(update, {
1158 branch: "effect-atom",
1159 });
1160 });
1161
1162 it("returns null when live git status is unavailable", () => {
1163 const update = resolveLiveThreadBranchUpdate({
1164 threadBranch: "feature/old-ref",
1165 gitStatus: null,
1166 });
1167
1168 assert.equal(update, null);
1169 });
1170
1171 it("returns null when the stored thread ref already matches git status", () => {
1172 const update = resolveLiveThreadBranchUpdate({
1173 threadBranch: "effect-atom",
1174 gitStatus: status({ refName: "effect-atom" }),
1175 });
1176
1177 assert.equal(update, null);
1178 });
1179
1180 it("returns null when git status is detached HEAD but the thread already has a ref", () => {
1181 const update = resolveLiveThreadBranchUpdate({
1182 threadBranch: "effect-atom",
1183 gitStatus: status({ refName: null }),
1184 });
1185
1186 assert.equal(update, null);
1187 });
1188
1189 it("does not regress a semantic thread ref back to a temporary worktree ref", () => {
1190 const update = resolveLiveThreadBranchUpdate({
1191 threadBranch: "t3code/github-query-rate-limit",
1192 gitStatus: status({ refName: "t3code/bda76797" }),
1193 });
1194
1195 assert.equal(update, null);
1196 });
1197
1198 it("allows a temporary worktree ref to reconcile to a semantic branch", () => {
1199 const update = resolveLiveThreadBranchUpdate({
1200 threadBranch: "t3code/a9628676",
1201 gitStatus: status({ refName: "feature/diff-panel-toggle" }),
1202 });
1203
1204 assert.deepEqual(update, { branch: "feature/diff-panel-toggle" });
1205 });
1206});
1207
1208describe("resolveThreadBranchMetadataPatch", () => {
1209 it("does not overwrite worktree metadata while reconciling a branch", () => {
1210 assert.deepEqual(
1211 resolveThreadBranchMetadataPatch("feature/current-ref", "feature/previous-ref"),
1212 {
1213 branch: "feature/current-ref",
1214 expectedBranch: "feature/previous-ref",
1215 },
1216 );
1217 });
1218});
1219
1220describe("resolveAutoFeatureBranchName", () => {
1221 it("uses semantic preferred ref names when available", () => {
1222 const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy");
1223 assert.equal(ref, "feature/fix-toast-copy");
1224 });
1225
1226 it("normalizes preferred names that already include a ref namespace", () => {
1227 const ref = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions");
1228 assert.equal(ref, "feature/refine-toolbar-actions");
1229 });
1230
1231 it("increments suffix when the preferred ref name already exists", () => {
1232 const ref = resolveAutoFeatureBranchName(
1233 ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"],
1234 "fix toast copy",
1235 );
1236 assert.equal(ref, "feature/fix-toast-copy-3");
1237 });
1238
1239 it("treats existing ref names as case-insensitive for collision checks", () => {
1240 const ref = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1");
1241 assert.equal(ref, "feature/ticket-1-2");
1242 });
1243
1244 it("falls back to feature/update when no preferred name is provided", () => {
1245 const ref = resolveAutoFeatureBranchName(["main"]);
1246 assert.equal(ref, "feature/update");
1247 });
1248});