1import { SourceControlProviderKind, type VcsStatusResult } from "@t3tools/contracts";
2import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
3import { resolveAutoFeatureBranchName } from "@t3tools/shared/git";
4import { assert, describe, it } from "vite-plus/test";
5import {
6 buildMenuItems,
7 hasUsableGitMenuContent,
8 requiresDefaultBranchConfirmation,
9 resolveChangesTotals,
10 resolveDefaultBranchActionDialogCopy,
11 resolveLiveThreadBranchUpdate,
12 resolveQuickAction,
13} from "./GitActionsControl.logic";
14
15function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
16 return {
17 isRepo: true,
18 hasPrimaryRemote: true,
19 isDefaultRef: false,
20 refName: "feature/test",
21 hasWorkingTreeChanges: false,
22 workingTree: {
23 files: [],
24 insertions: 0,
25 deletions: 0,
26 },
27 hasUpstream: true,
28 aheadCount: 0,
29 behindCount: 0,
30 pr: null,
31 ...overrides,
32 };
33}
34
35describe("when: ref is clean, ahead, and has an open PR", () => {
36 it("resolveQuickAction prefers push", () => {
37 const quick = resolveQuickAction(
38 status({
39 aheadCount: 3,
40 pr: {
41 number: 13,
42 title: "Open PR",
43 url: "https://example.com/pr/13",
44 baseRef: "main",
45 headRef: "feature/test",
46 state: "open",
47 },
48 }),
49 false,
50 );
51 assert.deepInclude(quick, { kind: "run_action", action: "push", label: "Push" });
52 });
53});
54
55describe("when: ref is clean, up to date, and has no open PR", () => {
56 it("enables create PR when synced with upstream but ahead of default, even with local changes", () => {
57 const syncedFeature = status({
58 aheadCount: 0,
59 behindCount: 0,
60 aheadOfDefaultCount: 1,
61 pr: null,
62 });
63
64 const quick = resolveQuickAction(syncedFeature, false);
65 assert.deepInclude(quick, {
66 label: "Create PR",
67 disabled: false,
68 kind: "run_action",
69 action: "create_pr",
70 });
71
72 const items = buildMenuItems(syncedFeature, false);
73 assert.equal(items.find((item) => item.id === "pr")?.disabled, false);
74
75 const dirtyFeature = { ...syncedFeature, hasWorkingTreeChanges: true };
76 assert.equal(
77 buildMenuItems(dirtyFeature, false).find((item) => item.id === "pr")?.disabled,
78 false,
79 );
80 });
81
82 it("offers the actions menu only when something in it is usable", () => {
83 const hasContent = (
84 gitStatus: VcsStatusResult,
85 overrides: Partial<Parameters<typeof hasUsableGitMenuContent>[0]> = {},
86 ) =>
87 hasUsableGitMenuContent({
88 menuItems: buildMenuItems(gitStatus, false, gitStatus.hasPrimaryRemote),
89 canWriteSourceControl: true,
90 canPublishRepository: false,
91 hasNotice: false,
92 ...overrides,
93 });
94 const clean = status();
95 const dirty = status({ hasWorkingTreeChanges: true });
96
97 assert.isFalse(hasContent(clean));
98 assert.isTrue(hasContent(dirty));
99 assert.isTrue(hasContent(status({ aheadCount: 1 })));
100 assert.isFalse(hasContent(dirty, { canWriteSourceControl: false }));
101 assert.isTrue(hasContent(status({ hasPrimaryRemote: false }), { canPublishRepository: true }));
102 assert.isTrue(hasContent(clean, { hasNotice: true }));
103 });
104});
105
106describe("when: on default ref without open PR", () => {
107 it("resolveQuickAction returns commit and push when local changes exist", () => {
108 const quick = resolveQuickAction(
109 status({ refName: "main", hasWorkingTreeChanges: true }),
110 false,
111 true,
112 );
113 assert.deepInclude(quick, {
114 kind: "run_action",
115 action: "commit_push",
116 label: "Commit & push",
117 disabled: false,
118 });
119 });
120});
121
122describe("when: local changes are behind upstream", () => {
123 it("keeps the commit action available without pushing, with or without an open PR", () => {
124 const behind = status({ hasWorkingTreeChanges: true, behindCount: 2 });
125 const commit = {
126 label: "Commit",
127 disabled: false,
128 kind: "run_action",
129 action: "commit",
130 } as const;
131 assert.deepEqual(resolveQuickAction(behind, false), commit);
132 assert.deepEqual(
133 resolveQuickAction(
134 {
135 ...behind,
136 pr: {
137 number: 13,
138 title: "Open PR",
139 url: "https://example.com/pr/13",
140 baseRef: "main",
141 headRef: "feature/test",
142 state: "open",
143 },
144 },
145 false,
146 ),
147 commit,
148 );
149 });
150});
151
152describe("when: ref has no upstream configured", () => {
153 it("resolveQuickAction runs push and create PR when no upstream and commits are ahead", () => {
154 const quick = resolveQuickAction(
155 status({
156 hasUpstream: false,
157 aheadCount: 2,
158 pr: null,
159 }),
160 false,
161 );
162 assert.deepInclude(quick, {
163 kind: "run_action",
164 action: "create_pr",
165 label: "Push & create PR",
166 disabled: false,
167 });
168 });
169
170 it("resolveQuickAction publishes when no origin remote exists", () => {
171 const quick = resolveQuickAction(
172 status({
173 hasUpstream: false,
174 aheadCount: 2,
175 pr: null,
176 }),
177 false,
178 false,
179 false,
180 );
181 assert.deepEqual(quick, {
182 kind: "open_publish",
183 label: "Publish repository",
184 disabled: false,
185 });
186 });
187});
188
189describe("when: the ref is on GitLab", () => {
190 const gitLab = {
191 sourceControlProvider: {
192 kind: SourceControlProviderKind.make("gitlab"),
193 name: "GitLab",
194 baseUrl: "https://gitlab.com",
195 },
196 };
197
198 it("names merge requests in actions and the default-ref dialog", () => {
199 const ahead = status({ ...gitLab, hasUpstream: false, aheadCount: 2 });
200 assert.equal(resolveQuickAction(ahead, false).label, "Push & create MR");
201 assert.equal(
202 resolveQuickAction(status({ ...gitLab, hasWorkingTreeChanges: true }), false).label,
203 "Commit, push & MR",
204 );
205 assert.equal(buildMenuItems(ahead, false).find((item) => item.id === "pr")?.label, "Create MR");
206
207 const copy = resolveDefaultBranchActionDialogCopy({
208 action: "create_pr",
209 branchName: "main",
210 includesCommit: false,
211 terminology: sourceControlClients.get("gitlab").changeRequest,
212 });
213 assert.equal(copy.title, "Push & create MR from default ref?");
214 assert.include(copy.description, "create a merge request on");
215 });
216});
217
218describe("requiresDefaultBranchConfirmation", () => {
219 it("requires confirmation for push actions on default ref", () => {
220 assert.isFalse(requiresDefaultBranchConfirmation("commit", true));
221 assert.isTrue(requiresDefaultBranchConfirmation("push", true));
222 assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true));
223 assert.isTrue(requiresDefaultBranchConfirmation("commit_push", true));
224 assert.isTrue(requiresDefaultBranchConfirmation("commit_push_pr", true));
225 assert.isFalse(requiresDefaultBranchConfirmation("commit_push", false));
226 assert.isFalse(requiresDefaultBranchConfirmation("push", false));
227 });
228});
229
230describe("resolveLiveThreadBranchUpdate", () => {
231 it("returns a branch update when live git status differs from stored thread metadata", () => {
232 const update = resolveLiveThreadBranchUpdate({
233 threadBranch: "feature/old-ref",
234 gitStatus: status({ refName: "effect-atom" }),
235 });
236
237 assert.deepEqual(update, {
238 branch: "effect-atom",
239 });
240 });
241
242 it("returns null when git status is detached HEAD but the thread already has a ref", () => {
243 const update = resolveLiveThreadBranchUpdate({
244 threadBranch: "effect-atom",
245 gitStatus: status({ refName: null }),
246 });
247
248 assert.equal(update, null);
249 });
250
251 it("does not regress a semantic thread ref back to a temporary worktree ref", () => {
252 const update = resolveLiveThreadBranchUpdate({
253 threadBranch: "t3code/github-query-rate-limit",
254 gitStatus: status({ refName: "t3code/bda76797" }),
255 });
256
257 assert.equal(update, null);
258 });
259});
260
261describe("resolveAutoFeatureBranchName", () => {
262 it("increments suffix when the preferred ref name already exists", () => {
263 const ref = resolveAutoFeatureBranchName(
264 ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"],
265 "fix toast copy",
266 );
267 assert.equal(ref, "feature/fix-toast-copy-3");
268 });
269
270 it("treats existing ref names as case-insensitive for collision checks", () => {
271 const ref = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1");
272 assert.equal(ref, "feature/ticket-1-2");
273 });
274});
275
276describe("resolveChangesTotals", () => {
277 it("does not let an empty branchChanges hide a dirty working tree", () => {
278 assert.deepInclude(
279 resolveChangesTotals(
280 status({
281 hasWorkingTreeChanges: true,
282 branchChanges: { baseRef: "main", insertions: 0, deletions: 0 },
283 workingTree: {
284 files: [{ path: "a.ts", insertions: 5, deletions: 1 }],
285 insertions: 5,
286 deletions: 1,
287 },
288 }),
289 ),
290 { insertions: 5, deletions: 1 },
291 );
292 });
293
294 it("keeps non-zero branchChanges over the working tree", () => {
295 assert.deepEqual(
296 resolveChangesTotals(
297 status({
298 branchChanges: { baseRef: "origin/master", insertions: 20, deletions: 3 },
299 workingTree: {
300 files: [{ path: "a.ts", insertions: 1, deletions: 0 }],
301 insertions: 1,
302 deletions: 0,
303 },
304 }),
305 ),
306 { insertions: 20, deletions: 3, baseRef: "origin/master" },
307 );
308 });
309
310 it("uses the panel's branch comparison when it has file stats", () => {
311 const dirty = status({
312 branchChanges: { baseRef: "main", insertions: 20, deletions: 3 },
313 });
314 const source = {
315 id: "branch-range",
316 kind: "branch-range",
317 title: "Changes vs release",
318 baseRef: "release",
319 headRef: "feature/test",
320 diff: "",
321 diffHash: "hash",
322 truncated: false,
323 } as const;
324 assert.deepEqual(
325 resolveChangesTotals(dirty, {
326 ...source,
327 files: [
328 { path: "a.ts", previousPath: null, additions: 4, deletions: 1 },
329 { path: "b.ts", previousPath: null, additions: 2, deletions: 0 },
330 ],
331 }),
332 { insertions: 6, deletions: 1, baseRef: "release" },
333 );
334 assert.deepEqual(resolveChangesTotals(dirty, source), {
335 insertions: 20,
336 deletions: 3,
337 baseRef: "main",
338 });
339 });
340
341 it("falls back to the working tree when branchChanges is omitted", () => {
342 assert.deepInclude(
343 resolveChangesTotals(
344 status({
345 workingTree: {
346 files: [{ path: "a.ts", insertions: 2, deletions: 0 }],
347 insertions: 2,
348 deletions: 0,
349 },
350 }),
351 ),
352 { insertions: 2, deletions: 0 },
353 );
354 });
355});