authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-14 21:40:42-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-14 23:35:48-08:00
log5a30594009cc1170cd181649899c86b5452eff15
tree646dcc7c78efa3b07c14ae6c5b7427db139e134c
parent02ca9324c5f67f82fc518edeb9c8b76dbfade819

git actions


12 files changed, 1164 insertions(+), 195 deletions(-)

apps/server/src/git.test.ts+37
......@@ -11,6 +11,7 @@ import {
1111 createGitWorktree,
1212 initGitRepo,
1313 listGitBranches,
14 pullGitBranch,
1415 removeGitWorktree,
1516 runTerminalCommand,
1617} from "./git";
......@@ -604,6 +605,42 @@ describe("git integration", () => {
604605 expect(skipped.status).toBe("skipped_up_to_date");
605606 });
606607
608 it("pulls behind branch and then reports up-to-date", async () => {
609 await using remote = await makeTmpDir();
610 await using source = await makeTmpDir();
611 await using clone = await makeTmpDir();
612 await git(remote.path, "init --bare");
613
614 await initRepoWithCommit(source.path);
615 const initialBranch = (await listGitBranches({ cwd: source.path })).branches.find(
616 (branch) => branch.current,
617 )!.name;
618 await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`);
619 await git(source.path, `push -u origin ${initialBranch}`);
620
621 await git(clone.path, `clone ${JSON.stringify(remote.path)} .`);
622 await git(clone.path, "config user.email 'test@test.com'");
623 await git(clone.path, "config user.name 'Test'");
624 await writeFile(path.join(clone.path, "CHANGELOG.md"), "remote change\n");
625 await git(clone.path, "add CHANGELOG.md");
626 await git(clone.path, "commit -m 'remote update'");
627 await git(clone.path, `push origin ${initialBranch}`);
628
629 const core = new GitCoreService();
630 const pulled = await core.pullCurrentBranch(source.path);
631 expect(pulled.status).toBe("pulled");
632 expect((await core.statusDetails(source.path)).behindCount).toBe(0);
633
634 const skipped = await core.pullCurrentBranch(source.path);
635 expect(skipped.status).toBe("skipped_up_to_date");
636 });
637
638 it("top-level pullGitBranch rejects when no upstream exists", async () => {
639 await using tmp = await makeTmpDir();
640 await initRepoWithCommit(tmp.path);
641 await expect(pullGitBranch({ cwd: tmp.path })).rejects.toThrow("no upstream");
642 });
643
607644 it("lists branches when recency lookup fails", async () => {
608645 await using tmp = await makeTmpDir();
609646 await initRepoWithCommit(tmp.path);
apps/server/src/git.ts+32
......@@ -3,6 +3,8 @@ import os from "node:os";
33import path from "node:path";
44
55import {
6 gitPullInputSchema,
7 gitPullResultSchema,
68 gitStatusInputSchema,
79 gitStatusResultSchema,
810 type GitCheckoutInput,
......@@ -12,6 +14,8 @@ import {
1214 type GitInitInput,
1315 type GitListBranchesInput,
1416 type GitListBranchesResult,
17 type GitPullInput,
18 type GitPullResult,
1519 type GitRemoveWorktreeInput,
1620 type GitStatusInput,
1721 type GitStatusResult,
......@@ -291,6 +295,29 @@ export class GitCoreService {
291295 };
292296 }
293297
298 async pullCurrentBranch(cwd: string): Promise<GitPullResult> {
299 const details = await this.statusDetails(cwd);
300 const branch = details.branch;
301 if (!branch) {
302 throw new Error("Cannot pull from detached HEAD.");
303 }
304 if (!details.hasUpstream) {
305 throw new Error("Current branch has no upstream configured. Push with upstream first.");
306 }
307 const beforeSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true));
308 await executeGit(cwd, ["pull", "--ff-only"], {
309 timeoutMs: 30_000,
310 fallbackErrorMessage: "git pull failed",
311 });
312 const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true));
313 const refreshed = await this.statusDetails(cwd);
314 return gitPullResultSchema.parse({
315 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",
316 branch,
317 ...(refreshed.upstreamRef ? { upstreamBranch: refreshed.upstreamRef } : {}),
318 });
319 }
320
294321 async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> {
295322 const range = `${baseBranch}..HEAD`;
296323 const [commitSummary, diffSummary, diffPatch] = await Promise.all([
......@@ -511,6 +538,11 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
511538 return defaultGitCoreService.listBranches(input);
512539}
513540
541export async function pullGitBranch(raw: GitPullInput): Promise<GitPullResult> {
542 const input = gitPullInputSchema.parse(raw);
543 return defaultGitCoreService.pullCurrentBranch(input.cwd);
544}
545
514546export async function createGitWorktree(
515547 input: GitCreateWorktreeInput,
516548): Promise<GitCreateWorktreeResult> {
apps/server/src/wsServer.test.ts+5
......@@ -545,6 +545,11 @@ describe("WebSocket Server", () => {
545545 });
546546 expect(afterInit.error).toBeUndefined();
547547 expect((afterInit.result as { isRepo: boolean }).isRepo).toBe(true);
548
549 const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: repoCwd });
550 expect(pullResponse.result).toBeUndefined();
551 expect(pullResponse.error?.message).toBeDefined();
552 expect(pullResponse.error?.message).not.toContain("Unknown method");
548553 });
549554
550555 it("responds to git.status via the git manager", async () => {
apps/server/src/wsServer.ts+4
......@@ -27,6 +27,7 @@ import {
2727 createGitWorktree,
2828 initGitRepo,
2929 listGitBranches,
30 pullGitBranch,
3031 removeGitWorktree,
3132} from "./git";
3233import { TerminalManager } from "./terminalManager";
......@@ -363,6 +364,9 @@ export function createServer(options: ServerOptions) {
363364 case WS_METHODS.gitStatus:
364365 return gitManager.status(request.params as never);
365366
367 case WS_METHODS.gitPull:
368 return pullGitBranch(request.params as never);
369
366370 case WS_METHODS.gitRunStackedAction:
367371 return gitManager.runStackedAction(request.params as never);
368372 case WS_METHODS.gitListBranches:
apps/web/src/components/GitActionsControl.logic.test.ts created+524
......@@ -0,0 +1,524 @@
1import type { GitStatusResult } from "@t3tools/contracts";
2import { assert, describe, it } from "vitest";
3import {
4 buildMenuItems,
5 requiresDefaultBranchConfirmation,
6 resolveQuickAction,
7} from "./GitActionsControl.logic";
8
9function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
10 return {
11 branch: "feature/test",
12 hasWorkingTreeChanges: false,
13 hasUpstream: true,
14 aheadCount: 0,
15 behindCount: 0,
16 openPr: null,
17 ...overrides,
18 };
19}
20
21describe("when: branch is clean and has an open PR", () => {
22 it("resolveQuickAction opens the existing PR", () => {
23 const quick = resolveQuickAction(
24 status({
25 openPr: {
26 number: 10,
27 title: "Open PR",
28 url: "https://example.com/pr/10",
29 baseBranch: "main",
30 headBranch: "feature/test",
31 },
32 }),
33 false,
34 );
35 assert.deepInclude(quick, { kind: "open_pr", label: "Open PR", disabled: false });
36 });
37
38 it("buildMenuItems disables commit/push and enables open PR", () => {
39 const items = buildMenuItems(
40 status({
41 openPr: {
42 number: 11,
43 title: "Existing PR",
44 url: "https://example.com/pr/11",
45 baseBranch: "main",
46 headBranch: "feature/test",
47 },
48 }),
49 false,
50 );
51 assert.deepEqual(items, [
52 {
53 id: "commit",
54 label: "Commit",
55 disabled: true,
56 icon: "commit",
57 kind: "open_dialog",
58 dialogAction: "commit",
59 },
60 {
61 id: "push",
62 label: "Push",
63 disabled: true,
64 icon: "push",
65 kind: "open_dialog",
66 dialogAction: "push",
67 },
68 {
69 id: "pr",
70 label: "Open PR",
71 disabled: false,
72 icon: "pr",
73 kind: "open_pr",
74 },
75 ]);
76 });
77});
78
79describe("when: actions are busy", () => {
80 it("resolveQuickAction returns running disabled state", () => {
81 const quick = resolveQuickAction(status(), true);
82 assert.deepInclude(quick, {
83 kind: "show_hint",
84 label: "Running...",
85 disabled: true,
86 hint: "Git action in progress.",
87 });
88 });
89
90 it("buildMenuItems disables all actions", () => {
91 const items = buildMenuItems(status(), true);
92 assert.deepEqual(items, [
93 {
94 id: "commit",
95 label: "Commit",
96 disabled: true,
97 icon: "commit",
98 kind: "open_dialog",
99 dialogAction: "commit",
100 },
101 {
102 id: "push",
103 label: "Push",
104 disabled: true,
105 icon: "push",
106 kind: "open_dialog",
107 dialogAction: "push",
108 },
109 {
110 id: "pr",
111 label: "Create PR",
112 disabled: true,
113 icon: "pr",
114 kind: "open_dialog",
115 dialogAction: "create_pr",
116 },
117 ]);
118 });
119});
120
121describe("when: git status is unavailable", () => {
122 it("resolveQuickAction returns unavailable disabled state", () => {
123 const quick = resolveQuickAction(null, false);
124 assert.deepInclude(quick, {
125 kind: "show_hint",
126 label: "Git actions",
127 disabled: true,
128 hint: "Git status is unavailable.",
129 });
130 });
131
132 it("buildMenuItems returns no menu items", () => {
133 const items = buildMenuItems(null, false);
134 assert.deepEqual(items, []);
135 });
136});
137
138describe("when: branch is clean, ahead, and has an open PR", () => {
139 it("resolveQuickAction prefers push", () => {
140 const quick = resolveQuickAction(
141 status({
142 aheadCount: 3,
143 openPr: {
144 number: 13,
145 title: "Open PR",
146 url: "https://example.com/pr/13",
147 baseBranch: "main",
148 headBranch: "feature/test",
149 },
150 }),
151 false,
152 );
153 assert.deepInclude(quick, { kind: "run_action", action: "commit_push", label: "Push" });
154 });
155
156 it("buildMenuItems enables push and keeps open PR available", () => {
157 const items = buildMenuItems(
158 status({
159 aheadCount: 2,
160 openPr: {
161 number: 12,
162 title: "Existing PR",
163 url: "https://example.com/pr/12",
164 baseBranch: "main",
165 headBranch: "feature/test",
166 },
167 }),
168 false,
169 );
170 assert.deepEqual(items, [
171 {
172 id: "commit",
173 label: "Commit",
174 disabled: true,
175 icon: "commit",
176 kind: "open_dialog",
177 dialogAction: "commit",
178 },
179 {
180 id: "push",
181 label: "Push",
182 disabled: false,
183 icon: "push",
184 kind: "open_dialog",
185 dialogAction: "push",
186 },
187 {
188 id: "pr",
189 label: "Open PR",
190 disabled: false,
191 icon: "pr",
192 kind: "open_pr",
193 },
194 ]);
195 });
196});
197
198describe("when: branch is clean, ahead, and has no open PR", () => {
199 it("resolveQuickAction pushes and creates a PR", () => {
200 const quick = resolveQuickAction(status({ aheadCount: 2, openPr: null }), false);
201 assert.deepInclude(quick, {
202 kind: "run_action",
203 action: "commit_push_pr",
204 label: "Push & create PR",
205 });
206 });
207
208 it("buildMenuItems enables push and create PR, with commit disabled", () => {
209 const items = buildMenuItems(status({ aheadCount: 2, openPr: null }), false);
210 assert.deepEqual(items, [
211 {
212 id: "commit",
213 label: "Commit",
214 disabled: true,
215 icon: "commit",
216 kind: "open_dialog",
217 dialogAction: "commit",
218 },
219 {
220 id: "push",
221 label: "Push",
222 disabled: false,
223 icon: "push",
224 kind: "open_dialog",
225 dialogAction: "push",
226 },
227 {
228 id: "pr",
229 label: "Create PR",
230 disabled: false,
231 icon: "pr",
232 kind: "open_dialog",
233 dialogAction: "create_pr",
234 },
235 ]);
236 });
237});
238
239describe("when: branch is clean, up to date, and has no open PR", () => {
240 it("resolveQuickAction returns disabled no-action state", () => {
241 const quick = resolveQuickAction(
242 status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, openPr: null }),
243 false,
244 );
245 assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true });
246 });
247
248 it("buildMenuItems disables commit, push, and create PR", () => {
249 const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, openPr: null }), false);
250 assert.deepEqual(items, [
251 {
252 id: "commit",
253 label: "Commit",
254 disabled: true,
255 icon: "commit",
256 kind: "open_dialog",
257 dialogAction: "commit",
258 },
259 {
260 id: "push",
261 label: "Push",
262 disabled: true,
263 icon: "push",
264 kind: "open_dialog",
265 dialogAction: "push",
266 },
267 {
268 id: "pr",
269 label: "Create PR",
270 disabled: true,
271 icon: "pr",
272 kind: "open_dialog",
273 dialogAction: "create_pr",
274 },
275 ]);
276 });
277});
278
279describe("when: branch is behind upstream", () => {
280 it("resolveQuickAction returns pull", () => {
281 const quick = resolveQuickAction(status({ behindCount: 2 }), false);
282 assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false });
283 });
284
285 it("buildMenuItems disables push and create PR", () => {
286 const items = buildMenuItems(status({ behindCount: 1, openPr: null }), false);
287 assert.deepEqual(items, [
288 {
289 id: "commit",
290 label: "Commit",
291 disabled: true,
292 icon: "commit",
293 kind: "open_dialog",
294 dialogAction: "commit",
295 },
296 {
297 id: "push",
298 label: "Push",
299 disabled: true,
300 icon: "push",
301 kind: "open_dialog",
302 dialogAction: "push",
303 },
304 {
305 id: "pr",
306 label: "Create PR",
307 disabled: true,
308 icon: "pr",
309 kind: "open_dialog",
310 dialogAction: "create_pr",
311 },
312 ]);
313 });
314});
315
316describe("when: working tree has local changes", () => {
317 it("resolveQuickAction returns commit", () => {
318 const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false);
319 assert.deepInclude(quick, { kind: "run_action", action: "commit", label: "Commit" });
320 });
321
322 it("buildMenuItems enables commit and disables push and PR", () => {
323 const items = buildMenuItems(status({ hasWorkingTreeChanges: true }), false);
324 assert.deepEqual(items, [
325 {
326 id: "commit",
327 label: "Commit",
328 disabled: false,
329 icon: "commit",
330 kind: "open_dialog",
331 dialogAction: "commit",
332 },
333 {
334 id: "push",
335 label: "Push",
336 disabled: true,
337 icon: "push",
338 kind: "open_dialog",
339 dialogAction: "push",
340 },
341 {
342 id: "pr",
343 label: "Create PR",
344 disabled: true,
345 icon: "pr",
346 kind: "open_dialog",
347 dialogAction: "create_pr",
348 },
349 ]);
350 });
351});
352
353describe("when: working tree has local changes and branch is behind upstream", () => {
354 it("resolveQuickAction still prefers commit", () => {
355 const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true, behindCount: 1 }), false);
356 assert.deepInclude(quick, { kind: "run_action", action: "commit", label: "Commit" });
357 });
358
359 it("buildMenuItems enables commit and keeps push and PR disabled", () => {
360 const items = buildMenuItems(status({ hasWorkingTreeChanges: true, behindCount: 2 }), false);
361 assert.deepEqual(items, [
362 {
363 id: "commit",
364 label: "Commit",
365 disabled: false,
366 icon: "commit",
367 kind: "open_dialog",
368 dialogAction: "commit",
369 },
370 {
371 id: "push",
372 label: "Push",
373 disabled: true,
374 icon: "push",
375 kind: "open_dialog",
376 dialogAction: "push",
377 },
378 {
379 id: "pr",
380 label: "Create PR",
381 disabled: true,
382 icon: "pr",
383 kind: "open_dialog",
384 dialogAction: "create_pr",
385 },
386 ]);
387 });
388});
389
390describe("when: HEAD is detached and there are no local changes", () => {
391 it("resolveQuickAction shows detached head hint", () => {
392 const quick = resolveQuickAction(
393 status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }),
394 false,
395 );
396 assert.deepInclude(quick, { kind: "show_hint", label: "Detached HEAD", disabled: false });
397 });
398
399 it("buildMenuItems keeps commit, push, and PR disabled", () => {
400 const items = buildMenuItems(status({ branch: null, hasWorkingTreeChanges: false }), false);
401 assert.deepEqual(items, [
402 {
403 id: "commit",
404 label: "Commit",
405 disabled: true,
406 icon: "commit",
407 kind: "open_dialog",
408 dialogAction: "commit",
409 },
410 {
411 id: "push",
412 label: "Push",
413 disabled: true,
414 icon: "push",
415 kind: "open_dialog",
416 dialogAction: "push",
417 },
418 {
419 id: "pr",
420 label: "Create PR",
421 disabled: true,
422 icon: "pr",
423 kind: "open_dialog",
424 dialogAction: "create_pr",
425 },
426 ]);
427 });
428});
429
430describe("when: branch has no upstream configured", () => {
431 it("resolveQuickAction runs push and create PR when clean and no open PR", () => {
432 const quick = resolveQuickAction(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false);
433 assert.deepInclude(quick, {
434 kind: "run_action",
435 action: "commit_push_pr",
436 label: "Push & create PR",
437 disabled: false,
438 });
439 });
440
441 it("resolveQuickAction opens PR when clean, no upstream, and no local commits ahead", () => {
442 const quick = resolveQuickAction(
443 status({
444 hasUpstream: false,
445 aheadCount: 0,
446 openPr: {
447 number: 14,
448 title: "Existing PR",
449 url: "https://example.com/pr/14",
450 baseBranch: "main",
451 headBranch: "feature/test",
452 },
453 }),
454 false,
455 );
456 assert.deepInclude(quick, {
457 kind: "open_pr",
458 label: "Open PR",
459 disabled: false,
460 });
461 });
462
463 it("resolveQuickAction runs push when clean, no upstream, and local commits are ahead", () => {
464 const quick = resolveQuickAction(
465 status({
466 hasUpstream: false,
467 aheadCount: 1,
468 openPr: {
469 number: 15,
470 title: "Existing PR",
471 url: "https://example.com/pr/15",
472 baseBranch: "main",
473 headBranch: "feature/test",
474 },
475 }),
476 false,
477 );
478 assert.deepInclude(quick, {
479 kind: "run_action",
480 action: "commit_push",
481 label: "Push",
482 disabled: false,
483 });
484 });
485
486 it("buildMenuItems enables push and disables create PR", () => {
487 const items = buildMenuItems(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false);
488 assert.deepEqual(items, [
489 {
490 id: "commit",
491 label: "Commit",
492 disabled: true,
493 icon: "commit",
494 kind: "open_dialog",
495 dialogAction: "commit",
496 },
497 {
498 id: "push",
499 label: "Push",
500 disabled: false,
501 icon: "push",
502 kind: "open_dialog",
503 dialogAction: "push",
504 },
505 {
506 id: "pr",
507 label: "Create PR",
508 disabled: true,
509 icon: "pr",
510 kind: "open_dialog",
511 dialogAction: "create_pr",
512 },
513 ]);
514 });
515});
516
517describe("requiresDefaultBranchConfirmation", () => {
518 it("requires confirmation only for push actions on default branch", () => {
519 assert.isFalse(requiresDefaultBranchConfirmation("commit", true));
520 assert.isTrue(requiresDefaultBranchConfirmation("commit_push", true));
521 assert.isTrue(requiresDefaultBranchConfirmation("commit_push_pr", true));
522 assert.isFalse(requiresDefaultBranchConfirmation("commit_push", false));
523 });
524});
apps/web/src/components/GitActionsControl.logic.ts created+197
......@@ -0,0 +1,197 @@
1import type { GitRunStackedActionResult, GitStackedAction, GitStatusResult } from "@t3tools/contracts";
2
3export type GitActionIconName = "commit" | "push" | "pr";
4
5export type GitDialogAction = "commit" | "push" | "create_pr";
6
7export interface GitActionMenuItem {
8 id: "commit" | "push" | "pr";
9 label: string;
10 disabled: boolean;
11 icon: GitActionIconName;
12 kind: "open_dialog" | "open_pr";
13 dialogAction?: GitDialogAction;
14}
15
16export interface GitQuickAction {
17 label: string;
18 disabled: boolean;
19 kind: "run_action" | "run_pull" | "open_pr" | "show_hint";
20 action?: GitStackedAction;
21 hint?: string;
22}
23
24export function describeGitResult(result: GitRunStackedActionResult): string {
25 const parts: string[] = [];
26
27 if (result.commit.status === "created") {
28 parts.push(result.commit.subject ?? "Committed changes");
29 }
30
31 if (result.push.status === "pushed") {
32 parts.push(
33 result.push.upstreamBranch
34 ? `Pushed to ${result.push.upstreamBranch}`
35 : "Pushed",
36 );
37 }
38
39 if (result.pr.status === "created") {
40 parts.push(result.pr.number ? `Created PR #${result.pr.number}` : "Created PR");
41 } else if (result.pr.status === "opened_existing") {
42 parts.push(result.pr.number ? `PR #${result.pr.number}` : "Opened existing PR");
43 }
44
45 return parts.join(" · ") || "No changes needed.";
46}
47
48export function buildMenuItems(
49 gitStatus: GitStatusResult | null,
50 isBusy: boolean,
51): GitActionMenuItem[] {
52 if (!gitStatus) return [];
53
54 const hasBranch = gitStatus.branch !== null;
55 const hasChanges = gitStatus.hasWorkingTreeChanges;
56 const hasOpenPr = gitStatus.openPr !== null;
57 const isBehind = gitStatus.behindCount > 0;
58 const canCommit = !isBusy && hasChanges;
59 const canPush =
60 !isBusy &&
61 hasBranch &&
62 !hasChanges &&
63 !isBehind &&
64 (gitStatus.aheadCount > 0 || !gitStatus.hasUpstream);
65 const canCreatePr =
66 !isBusy &&
67 hasBranch &&
68 !hasChanges &&
69 !hasOpenPr &&
70 gitStatus.aheadCount > 0 &&
71 gitStatus.hasUpstream &&
72 !isBehind;
73 const canOpenPr = !isBusy && hasOpenPr;
74
75 return [
76 {
77 id: "commit",
78 label: "Commit",
79 disabled: !canCommit,
80 icon: "commit",
81 kind: "open_dialog",
82 dialogAction: "commit",
83 },
84 {
85 id: "push",
86 label: "Push",
87 disabled: !canPush,
88 icon: "push",
89 kind: "open_dialog",
90 dialogAction: "push",
91 },
92 hasOpenPr
93 ? {
94 id: "pr",
95 label: "Open PR",
96 disabled: !canOpenPr,
97 icon: "pr",
98 kind: "open_pr",
99 }
100 : {
101 id: "pr",
102 label: "Create PR",
103 disabled: !canCreatePr,
104 icon: "pr",
105 kind: "open_dialog",
106 dialogAction: "create_pr",
107 },
108 ];
109}
110
111export function resolveQuickAction(
112 gitStatus: GitStatusResult | null,
113 isBusy: boolean,
114): GitQuickAction {
115 if (isBusy) {
116 return { label: "Running...", disabled: true, kind: "show_hint", hint: "Git action in progress." };
117 }
118
119 if (!gitStatus) {
120 return {
121 label: "Git actions",
122 disabled: true,
123 kind: "show_hint",
124 hint: "Git status is unavailable.",
125 };
126 }
127
128 const hasBranch = gitStatus.branch !== null;
129 const hasChanges = gitStatus.hasWorkingTreeChanges;
130 const hasOpenPr = gitStatus.openPr !== null;
131 const isBehind = gitStatus.behindCount > 0;
132 const canPush = hasBranch && !hasChanges && !isBehind;
133
134 if (!hasChanges && hasOpenPr && gitStatus.aheadCount === 0 && !isBehind) {
135 return { label: "Open PR", disabled: false, kind: "open_pr" };
136 }
137
138 if (!hasChanges && isBehind) {
139 return {
140 label: "Pull",
141 disabled: false,
142 kind: "run_pull",
143 };
144 }
145
146 if (!hasChanges && canPush && (gitStatus.aheadCount > 0 || !gitStatus.hasUpstream)) {
147 if (hasOpenPr) {
148 return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" };
149 }
150 return {
151 label: "Push & create PR",
152 disabled: false,
153 kind: "run_action",
154 action: "commit_push_pr",
155 };
156 }
157
158 if (hasChanges) {
159 return { label: "Commit", disabled: false, kind: "run_action", action: "commit" };
160 }
161
162 if (hasOpenPr) {
163 return { label: "Open PR", disabled: false, kind: "open_pr" };
164 }
165
166 if (hasBranch && gitStatus.hasUpstream && !isBehind) {
167 if (gitStatus.aheadCount > 0) {
168 return { label: "Create PR", disabled: false, kind: "run_action", action: "commit_push_pr" };
169 }
170 return {
171 label: "Commit",
172 disabled: true,
173 kind: "show_hint",
174 hint: "Branch is up to date. Nothing to commit, push, pull, or open as a PR.",
175 };
176 }
177
178 if (!hasBranch) {
179 return {
180 label: "Detached HEAD",
181 disabled: false,
182 kind: "show_hint",
183 hint: "Create and checkout a branch before pushing or opening a PR.",
184 };
185 }
186
187 return { label: "Commit", disabled: true, kind: "show_hint", hint: "No git action needed." };
188}
189
190export function requiresDefaultBranchConfirmation(
191 action: GitStackedAction,
192 isDefaultBranch: boolean,
193): boolean {
194 if (!isDefaultBranch) return false;
195 return action === "commit_push" || action === "commit_push_pr";
196}
197
apps/web/src/components/GitActionsControl.tsx+330-195
......@@ -1,16 +1,34 @@
11import {
2 type GitRunStackedActionResult,
32 type GitStackedAction,
4 type GitStatusResult,
53 type NativeApi,
64} from "@t3tools/contracts";
75import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
8import { useCallback, useEffect, useMemo, useState } from "react";
9import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon } from "lucide-react";
6import { useCallback, useMemo, useState } from "react";
7import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
108import { GitHubIcon } from "./Icons";
9import {
10 buildMenuItems,
11 type GitActionIconName,
12 type GitActionMenuItem,
13 type GitDialogAction,
14 type GitQuickAction,
15 describeGitResult,
16 requiresDefaultBranchConfirmation,
17 resolveQuickAction,
18} from "./GitActionsControl.logic";
1119import { Button } from "~/components/ui/button";
20import {
21 Dialog,
22 DialogDescription,
23 DialogFooter,
24 DialogHeader,
25 DialogPanel,
26 DialogPopup,
27 DialogTitle,
28} from "~/components/ui/dialog";
1229import { Group, GroupSeparator } from "~/components/ui/group";
1330import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
31import { Textarea } from "~/components/ui/textarea";
1432import { toastManager } from "~/components/ui/toast";
1533
1634interface GitActionsControlProps {
......@@ -18,102 +36,22 @@ interface GitActionsControlProps {
1836 gitCwd: string | null;
1937}
2038
21type GitActionIconName = "commit" | "push" | "pr";
22
23interface GitActionMenuItem {
24 id: "commit" | "push" | "pr";
25 label: string;
26 disabled: boolean;
27 icon: GitActionIconName;
28 kind: "run_action" | "open_pr";
29 action?: GitStackedAction;
30}
31
3239function GitActionItemIcon({ icon }: { icon: GitActionIconName }) {
3340 if (icon === "commit") return <GitCommitIcon />;
3441 if (icon === "push") return <CloudUploadIcon />;
3542 return <GitHubIcon />;
3643}
3744
38function describeGitResult(result: GitRunStackedActionResult): string {
39 const parts: string[] = [];
40
41 if (result.commit.status === "created") {
42 parts.push(result.commit.subject ?? "Committed changes");
45function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
46 if (quickAction.kind === "open_pr") return <GitHubIcon className="size-4" />;
47 if (quickAction.kind === "run_pull") return <InfoIcon className="size-4" />;
48 if (quickAction.kind === "run_action") {
49 if (quickAction.action === "commit") return <GitCommitIcon className="size-4" />;
50 if (quickAction.action === "commit_push") return <CloudUploadIcon className="size-4" />;
51 return <GitHubIcon className="size-4" />;
4352 }
44
45 if (result.push.status === "pushed") {
46 parts.push(
47 result.push.upstreamBranch
48 ? `Pushed to ${result.push.upstreamBranch}`
49 : "Pushed",
50 );
51 }
52
53 if (result.pr.status === "created") {
54 parts.push(result.pr.number ? `Created PR #${result.pr.number}` : "Created PR");
55 } else if (result.pr.status === "opened_existing") {
56 parts.push(result.pr.number ? `PR #${result.pr.number}` : "Opened existing PR");
57 }
58
59 return parts.join(" · ") || "No changes needed.";
60}
61
62function buildGitActionMenuItems(
63 gitStatus: GitStatusResult | null,
64 isDisabled: boolean,
65): GitActionMenuItem[] {
66 if (!gitStatus) return [];
67
68 const hasBranch = gitStatus.branch !== null;
69 const hasOpenPr = gitStatus.openPr !== null;
70 const hasChanges = gitStatus.hasWorkingTreeChanges;
71 const hasAhead = gitStatus.aheadCount > 0;
72 const canCommit = !isDisabled && hasChanges;
73 const canPush = !isDisabled && hasBranch && !hasChanges && hasAhead;
74 const canOpenPr = !isDisabled && hasOpenPr;
75 const canCreatePr =
76 !isDisabled &&
77 hasBranch &&
78 !hasOpenPr &&
79 !hasChanges &&
80 gitStatus.hasUpstream &&
81 gitStatus.behindCount === 0;
82
83 return [
84 {
85 id: "commit",
86 label: "Commit",
87 disabled: !canCommit,
88 icon: "commit",
89 kind: "run_action",
90 action: "commit",
91 },
92 {
93 id: "push",
94 label: "Push",
95 disabled: !canPush,
96 icon: "push",
97 kind: "run_action",
98 action: "commit_push",
99 },
100 hasOpenPr
101 ? {
102 id: "pr",
103 label: "View PR",
104 disabled: !canOpenPr,
105 icon: "pr",
106 kind: "open_pr",
107 }
108 : {
109 id: "pr",
110 label: "Create PR",
111 disabled: !canCreatePr,
112 icon: "pr",
113 kind: "run_action",
114 action: "commit_push_pr",
115 },
116 ];
53 if (quickAction.label === "Commit") return <GitCommitIcon className="size-4" />;
54 return <InfoIcon className="size-4" />;
11755}
11856
11957function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] {
......@@ -135,34 +73,64 @@ function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null
13573
13674export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {
13775 const queryClient = useQueryClient();
138 const [selectedGitActionId, setSelectedGitActionId] = useState<GitActionMenuItem["id"]>("commit");
76 const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null);
77 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
13978
14079 const { data: gitStatus = null, error: gitStatusError } = useQuery(
14180 gitStatusQueryOptions(api, gitCwd),
14281 );
14382
83 const { data: branchList = null } = useQuery({
84 queryKey: ["git", "branches", gitCwd],
85 queryFn: async () => {
86 if (!api || !gitCwd) throw new Error("Git branches are unavailable.");
87 return api.git.listBranches({ cwd: gitCwd });
88 },
89 enabled: !!api && !!gitCwd,
90 });
91
14492 const runImmediateGitActionMutation = useMutation({
145 mutationFn: async (action: GitStackedAction) => {
93 mutationFn: async ({
94 action,
95 commitMessage,
96 }: {
97 action: GitStackedAction;
98 commitMessage?: string;
99 }) => {
146100 if (!api || !gitCwd) {
147101 throw new Error("Git action is unavailable.");
148102 }
149 return api.git.runStackedAction({ cwd: gitCwd, action });
103 return api.git.runStackedAction({ cwd: gitCwd, action, ...(commitMessage ? { commitMessage } : {}) });
104 },
105 onSuccess: async () => {
106 await queryClient.invalidateQueries({ queryKey: ["git"] });
107 },
108 });
109 const pullMutation = useMutation({
110 mutationFn: async () => {
111 if (!api || !gitCwd) throw new Error("Git pull is unavailable.");
112 return api.git.pull({ cwd: gitCwd });
150113 },
151114 onSuccess: async () => {
152115 await queryClient.invalidateQueries({ queryKey: ["git"] });
153116 },
154117 });
155118
156 const isGitActionRunning = runImmediateGitActionMutation.isPending;
119 const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending;
120 const isDefaultBranch = useMemo(() => {
121 const currentBranch = gitStatus?.branch;
122 if (!currentBranch) return false;
123 const current = branchList?.branches.find((branch) => branch.name === currentBranch);
124 return current?.isDefault ?? (currentBranch === "main" || currentBranch === "master");
125 }, [branchList?.branches, gitStatus?.branch]);
157126
158 const gitBaseDisabled = !api || !gitCwd || !gitStatus || isGitActionRunning;
159127 const gitActionMenuItems = useMemo(
160 () => buildGitActionMenuItems(gitStatus, gitBaseDisabled),
161 [gitBaseDisabled, gitStatus],
128 () => buildMenuItems(gitStatus, isGitActionRunning),
129 [gitStatus, isGitActionRunning],
162130 );
163 const selectedGitActionItem = useMemo(
164 () => gitActionMenuItems.find((item) => item.id === selectedGitActionId) ?? null,
165 [gitActionMenuItems, selectedGitActionId],
131 const quickAction = useMemo(
132 () => resolveQuickAction(gitStatus, isGitActionRunning),
133 [gitStatus, isGitActionRunning],
166134 );
167135
168136 const refreshGitStatus = useCallback(async () => {
......@@ -170,15 +138,71 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
170138 await queryClient.invalidateQueries({ queryKey: ["git"] });
171139 }, [api, gitCwd, queryClient]);
172140
173 const runGitActionImmediately = useCallback(
174 (action: GitStackedAction) => {
175 const promise = runImmediateGitActionMutation.mutateAsync(action);
141 const maybeConfirmPushToDefaultBranch = useCallback(
142 async (action: GitStackedAction): Promise<boolean> => {
143 if (!api) return false;
144 if (!requiresDefaultBranchConfirmation(action, isDefaultBranch) || !gitStatus?.branch) {
145 return true;
146 }
147 return api.dialogs.confirm(
148 `Push to default branch "${gitStatus.branch}"? This will update the shared base branch.`,
149 );
150 },
151 [api, gitStatus?.branch, isDefaultBranch],
152 );
153
154 const openExistingPr = useCallback(async () => {
155 if (!api) {
156 toastManager.add({ type: "error", title: "Link opening is unavailable." });
157 return;
158 }
159 const prUrl = gitStatus?.openPr?.url ?? null;
160 if (!prUrl) {
161 toastManager.add({ type: "error", title: "No open PR found." });
162 return;
163 }
164 const promise = api.shell.openExternal(prUrl);
165 toastManager.promise(promise, {
166 loading: { title: "Opening PR..." },
167 success: { title: "PR opened" },
168 error: (err) => ({
169 title: "Unable to open PR link",
170 description: err instanceof Error ? err.message : "An error occurred.",
171 }),
172 });
173 void promise.catch(() => undefined);
174 }, [api, gitStatus?.openPr?.url]);
175
176 const runGitActionWithToast = useCallback(
177 async ({
178 action,
179 commitMessage,
180 }: {
181 action: GitStackedAction;
182 commitMessage?: string;
183 }) => {
184 const confirmed = await maybeConfirmPushToDefaultBranch(action);
185 if (!confirmed) return;
186
187 const promise = runImmediateGitActionMutation.mutateAsync({
188 action,
189 ...(commitMessage ? { commitMessage } : {}),
190 });
176191 toastManager.promise(promise, {
177192 loading: {
178 title: action === "commit_push" ? "Pushing..." : "Creating PR...",
193 title:
194 action === "commit"
195 ? "Committing..."
196 : action === "commit_push"
197 ? "Pushing..."
198 : "Creating PR...",
179199 },
180200 success: (result) => {
181 const prUrl = result.pr.url;
201 const prUrl = result.pr.url ?? gitStatus?.openPr?.url;
202 const shouldOfferCreatePrCta =
203 action === "commit_push" &&
204 !prUrl &&
205 result.push.status === "pushed";
182206 return {
183207 title: "Done",
184208 description: describeGitResult(result),
......@@ -190,7 +214,15 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
190214 onClick: () => void api?.shell.openExternal(prUrl),
191215 },
192216 }
193 : {}),
217 : shouldOfferCreatePrCta
218 ? {
219 timeout: 10_000,
220 actionProps: {
221 children: "Create PR",
222 onClick: () => setActiveDialogAction("create_pr"),
223 },
224 }
225 : {}),
194226 };
195227 },
196228 error: (err) => ({
......@@ -200,114 +232,217 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
200232 });
201233 void promise.catch(() => undefined);
202234 },
203 [api, runImmediateGitActionMutation],
235 [
236 api,
237 gitStatus?.openPr?.url,
238 maybeConfirmPushToDefaultBranch,
239 runImmediateGitActionMutation,
240 setActiveDialogAction,
241 ],
204242 );
205243
206 const openExistingPr = useCallback(() => {
207 if (!api) {
208 toastManager.add({ type: "error", title: "Link opening is unavailable." });
244 const runQuickAction = useCallback(() => {
245 if (quickAction.kind === "open_pr") {
246 void openExistingPr();
209247 return;
210248 }
211
212 const prUrl = gitStatus?.openPr?.url ?? null;
213 if (!prUrl) {
214 toastManager.add({ type: "error", title: "No open PR found." });
249 if (quickAction.kind === "run_pull") {
250 const promise = pullMutation.mutateAsync();
251 toastManager.promise(promise, {
252 loading: { title: "Pulling..." },
253 success: (result) => ({
254 title: result.status === "pulled" ? "Pulled" : "Already up to date",
255 description:
256 result.status === "pulled"
257 ? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}`
258 : `${result.branch} is already synchronized.`,
259 }),
260 error: (err) => ({
261 title: "Pull failed",
262 description: err instanceof Error ? err.message : "An error occurred.",
263 }),
264 });
265 void promise.catch(() => undefined);
215266 return;
216267 }
217
218 void api.shell.openExternal(prUrl).catch((error) => {
268 if (quickAction.kind === "show_hint") {
219269 toastManager.add({
220 type: "error",
221 title: "Unable to open PR link",
222 description: error instanceof Error ? error.message : undefined,
270 type: "info",
271 title: quickAction.label,
272 description: quickAction.hint,
223273 });
224 });
225 }, [api, gitStatus?.openPr?.url]);
226
227 const runSelectedAction = useCallback(() => {
228 if (!selectedGitActionItem || selectedGitActionItem.disabled) return;
229 if (selectedGitActionItem.kind === "open_pr") {
230 openExistingPr();
231274 return;
232275 }
233 if (selectedGitActionItem.action) {
234 runGitActionImmediately(selectedGitActionItem.action);
276 if (quickAction.action) {
277 void runGitActionWithToast({ action: quickAction.action });
235278 }
236 }, [openExistingPr, runGitActionImmediately, selectedGitActionItem]);
279 }, [openExistingPr, pullMutation, quickAction, runGitActionWithToast]);
280
281 const openDialogForMenuItem = useCallback(
282 (item: GitActionMenuItem) => {
283 if (item.disabled) return;
284 if (item.kind === "open_pr") {
285 void openExistingPr();
286 return;
287 }
288 if (item.dialogAction) {
289 setActiveDialogAction(item.dialogAction);
290 }
291 },
292 [openExistingPr],
293 );
237294
238 useEffect(() => {
239 setSelectedGitActionId("commit");
240 }, [gitCwd]);
295 const runDialogAction = useCallback(() => {
296 if (!activeDialogAction) return;
297 const action: GitStackedAction =
298 activeDialogAction === "commit"
299 ? "commit"
300 : activeDialogAction === "push"
301 ? "commit_push"
302 : "commit_push_pr";
303 const commitMessage =
304 activeDialogAction === "commit" ? dialogCommitMessage.trim() : "";
305 setActiveDialogAction(null);
306 void runGitActionWithToast({
307 action,
308 ...(commitMessage ? { commitMessage } : {}),
309 });
310 }, [activeDialogAction, dialogCommitMessage, runGitActionWithToast]);
241311
242 useEffect(() => {
243 if (selectedGitActionItem) return;
244 const fallback = gitActionMenuItems.find((item) => !item.disabled) ?? gitActionMenuItems[0];
245 if (fallback) {
246 setSelectedGitActionId(fallback.id);
247 }
248 }, [gitActionMenuItems, selectedGitActionItem]);
312 const dialogTitle =
313 activeDialogAction === "commit"
314 ? "Commit changes"
315 : activeDialogAction === "push"
316 ? "Push branch"
317 : "Create pull request";
318 const dialogDescription =
319 activeDialogAction === "commit"
320 ? "Review and confirm your commit. Leave the message blank to auto-generate one."
321 : activeDialogAction === "push"
322 ? "Push this branch now. If this is the default branch, you'll be asked to confirm."
323 : "Create a pull request using generated title/body content.";
249324
250325 if (!gitCwd) return null;
251326
252327 return (
253 <Group aria-label="Git actions">
254 <Button
255 variant="outline"
256 size="xs"
257 disabled={!selectedGitActionItem || selectedGitActionItem.disabled || isGitActionRunning}
258 onClick={runSelectedAction}
259 >
260 {isGitActionRunning ? "Running..." : selectedGitActionItem?.label ?? "Git actions"}
261 </Button>
262 <GroupSeparator />
263 <Menu
328 <>
329 <Group aria-label="Git actions">
330 <Button
331 variant="outline"
332 size="xs"
333 disabled={isGitActionRunning || quickAction.disabled}
334 onClick={runQuickAction}
335 >
336 <GitQuickActionIcon quickAction={quickAction} />
337 {quickAction.label}
338 </Button>
339 <GroupSeparator />
340 <Menu
341 onOpenChange={(open) => {
342 if (open) void refreshGitStatus();
343 }}
344 >
345 <MenuTrigger
346 render={<Button aria-label="Git action options" size="icon-xs" variant="outline" />}
347 disabled={isGitActionRunning}
348 >
349 <ChevronDownIcon aria-hidden="true" className="size-4" />
350 </MenuTrigger>
351 <MenuPopup align="end" sideOffset={4}>
352 {gitActionMenuItems.map((item) => (
353 <MenuItem
354 key={`${item.id}-${item.label}`}
355 disabled={item.disabled}
356 onClick={() => {
357 openDialogForMenuItem(item);
358 }}
359 >
360 <GitActionItemIcon icon={item.icon} />
361 {item.label}
362 </MenuItem>
363 ))}
364 {gitStatus?.branch === null && (
365 <p className="px-2 py-1.5 text-xs text-warning">
366 Detached HEAD: create and checkout a branch to enable push and PR actions.
367 </p>
368 )}
369 {gitStatus &&
370 gitStatus.branch !== null &&
371 !gitStatus.hasWorkingTreeChanges &&
372 gitStatus.behindCount > 0 && (
373 <p className="px-2 py-1.5 text-xs text-warning">
374 Behind upstream. Pull/rebase first.
375 </p>
376 )}
377 {gitStatusError && (
378 <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p>
379 )}
380 </MenuPopup>
381 </Menu>
382 </Group>
383
384 <Dialog
385 open={activeDialogAction !== null}
264386 onOpenChange={(open) => {
265 if (open) void refreshGitStatus();
387 if (!open) {
388 setActiveDialogAction(null);
389 setDialogCommitMessage("");
390 }
266391 }}
267392 >
268 <MenuTrigger
269 render={<Button aria-label="Git action options" size="icon-xs" variant="outline" />}
270 disabled={!gitStatus || isGitActionRunning}
271 >
272 <ChevronDownIcon aria-hidden="true" className="size-4" />
273 </MenuTrigger>
274 <MenuPopup align="end" sideOffset={4}>
275 {gitActionMenuItems.map((item) => (
276 <MenuItem
277 key={`${item.id}-${item.label}`}
278 disabled={item.disabled}
393 <DialogPopup>
394 <DialogHeader>
395 <DialogTitle>{dialogTitle}</DialogTitle>
396 <DialogDescription>{dialogDescription}</DialogDescription>
397 </DialogHeader>
398 <DialogPanel className="space-y-4">
399 <div className="rounded-lg border border-input bg-muted/40 p-3 text-xs">
400 <p>
401 <span className="font-medium">Branch:</span>{" "}
402 {gitStatus?.branch ?? "(detached HEAD)"}
403 </p>
404 <p>
405 <span className="font-medium">Working tree:</span>{" "}
406 {gitStatus?.hasWorkingTreeChanges ? "Has changes" : "Clean"}
407 </p>
408 <p>
409 <span className="font-medium">Ahead/behind:</span>{" "}
410 {gitStatus ? `${gitStatus.aheadCount}/${gitStatus.behindCount}` : "unknown"}
411 </p>
412 </div>
413 {activeDialogAction === "commit" && (
414 <div className="space-y-1">
415 <p className="text-xs font-medium">Commit message (optional)</p>
416 <Textarea
417 value={dialogCommitMessage}
418 onChange={(event) => setDialogCommitMessage(event.target.value)}
419 placeholder="Leave empty to auto-generate"
420 size="sm"
421 />
422 </div>
423 )}
424 </DialogPanel>
425 <DialogFooter>
426 <Button
427 variant="outline"
428 size="sm"
279429 onClick={() => {
280 setSelectedGitActionId(item.id);
281 if (item.kind === "open_pr") {
282 openExistingPr();
283 } else if (item.action) {
284 runGitActionImmediately(item.action);
285 }
430 setActiveDialogAction(null);
431 setDialogCommitMessage("");
286432 }}
287433 >
288 <GitActionItemIcon icon={item.icon} />
289 {item.label}
290 </MenuItem>
291 ))}
292 {gitStatus?.branch === null && (
293 <p className="px-2 py-1.5 text-xs text-warning">
294 Detached HEAD: push and PR are unavailable.
295 </p>
296 )}
297 {gitStatus &&
298 gitStatus.branch !== null &&
299 !gitStatus.hasWorkingTreeChanges &&
300 gitStatus.aheadCount === 0 &&
301 gitStatus.behindCount > 0 && (
302 <p className="px-2 py-1.5 text-xs text-warning">
303 Behind upstream. Pull/rebase first.
304 </p>
305 )}
306 {gitStatusError && (
307 <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p>
308 )}
309 </MenuPopup>
310 </Menu>
311 </Group>
434 Cancel
435 </Button>
436 <Button size="sm" onClick={runDialogAction}>
437 {activeDialogAction === "commit"
438 ? "Commit"
439 : activeDialogAction === "push"
440 ? "Push"
441 : "Create PR"}
442 </Button>
443 </DialogFooter>
444 </DialogPopup>
445 </Dialog>
446 </>
312447 );
313448}
apps/web/src/wsNativeApi.ts+1
......@@ -118,6 +118,7 @@ export function createWsNativeApi(): NativeApi {
118118 },
119119 },
120120 git: {
121 pull: (input) => transport.request(WS_METHODS.gitPull, input),
121122 status: (input) => transport.request(WS_METHODS.gitStatus, input),
122123 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),
123124 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
packages/contracts/src/git.test.ts+17
......@@ -1,6 +1,8 @@
11import { describe, expect, it } from "vitest";
22
33import {
4 gitPullInputSchema,
5 gitPullResultSchema,
46 gitRunStackedActionInputSchema,
57 gitRunStackedActionResultSchema,
68 gitStackedActionSchema,
......@@ -14,6 +16,11 @@ describe("git contracts", () => {
1416 expect(parsed.cwd).toBe("/tmp/repo");
1517 });
1618
19 it("parses git pull input and trims cwd", () => {
20 const parsed = gitPullInputSchema.parse({ cwd: " /tmp/repo " });
21 expect(parsed.cwd).toBe("/tmp/repo");
22 });
23
1724 it("parses git status result", () => {
1825 const parsed = gitStatusResultSchema.parse({
1926 branch: "feature/git-actions",
......@@ -79,4 +86,14 @@ describe("git contracts", () => {
7986 expect(parsed.commit.status).toBe("created");
8087 expect(parsed.pr.number).toBe(123);
8188 });
89
90 it("parses git pull result", () => {
91 const parsed = gitPullResultSchema.parse({
92 status: "pulled",
93 branch: "feature/my-branch",
94 upstreamBranch: "origin/feature/my-branch",
95 });
96 expect(parsed.status).toBe("pulled");
97 expect(parsed.branch).toBe("feature/my-branch");
98 });
8299});
packages/contracts/src/git.ts+13
......@@ -68,6 +68,10 @@ export const gitStatusInputSchema = z.object({
6868 cwd: z.string().trim().min(1),
6969});
7070
71export const gitPullInputSchema = z.object({
72 cwd: z.string().trim().min(1),
73});
74
7175export const gitStatusResultSchema = z.object({
7276 branch: z.string().min(1).nullable(),
7377 hasWorkingTreeChanges: z.boolean(),
......@@ -133,7 +137,16 @@ export const gitRunStackedActionResultSchema = z.object({
133137});
134138
135139export type GitStatusInput = z.input<typeof gitStatusInputSchema>;
140export type GitPullInput = z.input<typeof gitPullInputSchema>;
136141export type GitStatusResult = z.infer<typeof gitStatusResultSchema>;
137142export type GitStackedAction = z.infer<typeof gitStackedActionSchema>;
138143export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>;
139144export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>;
145
146export const gitPullResultSchema = z.object({
147 status: z.enum(["pulled", "skipped_up_to_date"]),
148 branch: z.string().min(1),
149 upstreamBranch: z.string().min(1).optional(),
150});
151
152export type GitPullResult = z.infer<typeof gitPullResultSchema>;
packages/contracts/src/ipc.ts+3
......@@ -7,6 +7,8 @@ import type {
77 GitInitInput,
88 GitListBranchesInput,
99 GitListBranchesResult,
10 GitPullInput,
11 GitPullResult,
1012 GitRemoveWorktreeInput,
1113 GitRunStackedActionInput,
1214 GitRunStackedActionResult,
......@@ -101,6 +103,7 @@ export interface NativeApi {
101103 checkout: (input: GitCheckoutInput) => Promise<void>;
102104 init: (input: GitInitInput) => Promise<void>;
103105 // Stacked action API
106 pull: (input: GitPullInput) => Promise<GitPullResult>;
104107 status: (input: GitStatusInput) => Promise<GitStatusResult>;
105108 runStackedAction: (
106109 input: GitRunStackedActionInput,
packages/contracts/src/ws.ts+1
......@@ -20,6 +20,7 @@ export const WS_METHODS = {
2020 shellOpenInEditor: "shell.openInEditor",
2121
2222 // Git methods
23 gitPull: "git.pull",
2324 gitStatus: "git.status",
2425 gitRunStackedAction: "git.runStackedAction",
2526 gitListBranches: "git.listBranches",