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 {...@@ -11,6 +11,7 @@ import {
11 createGitWorktree,11 createGitWorktree,
12 initGitRepo,12 initGitRepo,
13 listGitBranches,13 listGitBranches,
14 pullGitBranch,
14 removeGitWorktree,15 removeGitWorktree,
15 runTerminalCommand,16 runTerminalCommand,
16} from "./git";17} from "./git";
...@@ -604,6 +605,42 @@ describe("git integration", () => {...@@ -604,6 +605,42 @@ describe("git integration", () => {
604 expect(skipped.status).toBe("skipped_up_to_date");605 expect(skipped.status).toBe("skipped_up_to_date");
605 });606 });
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
607 it("lists branches when recency lookup fails", async () => {644 it("lists branches when recency lookup fails", async () => {
608 await using tmp = await makeTmpDir();645 await using tmp = await makeTmpDir();
609 await initRepoWithCommit(tmp.path);646 await initRepoWithCommit(tmp.path);
apps/server/src/git.ts+32
...@@ -3,6 +3,8 @@ import os from "node:os";...@@ -3,6 +3,8 @@ import os from "node:os";
3import path from "node:path";3import path from "node:path";
44
5import {5import {
6 gitPullInputSchema,
7 gitPullResultSchema,
6 gitStatusInputSchema,8 gitStatusInputSchema,
7 gitStatusResultSchema,9 gitStatusResultSchema,
8 type GitCheckoutInput,10 type GitCheckoutInput,
...@@ -12,6 +14,8 @@ import {...@@ -12,6 +14,8 @@ import {
12 type GitInitInput,14 type GitInitInput,
13 type GitListBranchesInput,15 type GitListBranchesInput,
14 type GitListBranchesResult,16 type GitListBranchesResult,
17 type GitPullInput,
18 type GitPullResult,
15 type GitRemoveWorktreeInput,19 type GitRemoveWorktreeInput,
16 type GitStatusInput,20 type GitStatusInput,
17 type GitStatusResult,21 type GitStatusResult,
...@@ -291,6 +295,29 @@ export class GitCoreService {...@@ -291,6 +295,29 @@ export class GitCoreService {
291 };295 };
292 }296 }
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
294 async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> {321 async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> {
295 const range = `${baseBranch}..HEAD`;322 const range = `${baseBranch}..HEAD`;
296 const [commitSummary, diffSummary, diffPatch] = await Promise.all([323 const [commitSummary, diffSummary, diffPatch] = await Promise.all([
...@@ -511,6 +538,11 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL...@@ -511,6 +538,11 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
511 return defaultGitCoreService.listBranches(input);538 return defaultGitCoreService.listBranches(input);
512}539}
513540
541export async function pullGitBranch(raw: GitPullInput): Promise<GitPullResult> {
542 const input = gitPullInputSchema.parse(raw);
543 return defaultGitCoreService.pullCurrentBranch(input.cwd);
544}
545
514export async function createGitWorktree(546export async function createGitWorktree(
515 input: GitCreateWorktreeInput,547 input: GitCreateWorktreeInput,
516): Promise<GitCreateWorktreeResult> {548): Promise<GitCreateWorktreeResult> {
apps/server/src/wsServer.test.ts+5
...@@ -545,6 +545,11 @@ describe("WebSocket Server", () => {...@@ -545,6 +545,11 @@ describe("WebSocket Server", () => {
545 });545 });
546 expect(afterInit.error).toBeUndefined();546 expect(afterInit.error).toBeUndefined();
547 expect((afterInit.result as { isRepo: boolean }).isRepo).toBe(true);547 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");
548 });553 });
549554
550 it("responds to git.status via the git manager", async () => {555 it("responds to git.status via the git manager", async () => {
apps/server/src/wsServer.ts+4
...@@ -27,6 +27,7 @@ import {...@@ -27,6 +27,7 @@ import {
27 createGitWorktree,27 createGitWorktree,
28 initGitRepo,28 initGitRepo,
29 listGitBranches,29 listGitBranches,
30 pullGitBranch,
30 removeGitWorktree,31 removeGitWorktree,
31} from "./git";32} from "./git";
32import { TerminalManager } from "./terminalManager";33import { TerminalManager } from "./terminalManager";
...@@ -363,6 +364,9 @@ export function createServer(options: ServerOptions) {...@@ -363,6 +364,9 @@ export function createServer(options: ServerOptions) {
363 case WS_METHODS.gitStatus:364 case WS_METHODS.gitStatus:
364 return gitManager.status(request.params as never);365 return gitManager.status(request.params as never);
365366
367 case WS_METHODS.gitPull:
368 return pullGitBranch(request.params as never);
369
366 case WS_METHODS.gitRunStackedAction:370 case WS_METHODS.gitRunStackedAction:
367 return gitManager.runStackedAction(request.params as never);371 return gitManager.runStackedAction(request.params as never);
368 case WS_METHODS.gitListBranches:372 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 @@...@@ -1,16 +1,34 @@
1import {1import {
2 type GitRunStackedActionResult,
3 type GitStackedAction,2 type GitStackedAction,
4 type GitStatusResult,
5 type NativeApi,3 type NativeApi,
6} from "@t3tools/contracts";4} from "@t3tools/contracts";
7import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";5import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
8import { useCallback, useEffect, useMemo, useState } from "react";6import { useCallback, useMemo, useState } from "react";
9import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon } from "lucide-react";7import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
10import { GitHubIcon } from "./Icons";8import { 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";
11import { Button } from "~/components/ui/button";19import { 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";
12import { Group, GroupSeparator } from "~/components/ui/group";29import { Group, GroupSeparator } from "~/components/ui/group";
13import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";30import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
31import { Textarea } from "~/components/ui/textarea";
14import { toastManager } from "~/components/ui/toast";32import { toastManager } from "~/components/ui/toast";
1533
16interface GitActionsControlProps {34interface GitActionsControlProps {
...@@ -18,102 +36,22 @@ interface GitActionsControlProps {...@@ -18,102 +36,22 @@ interface GitActionsControlProps {
18 gitCwd: string | null;36 gitCwd: string | null;
19}37}
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
32function GitActionItemIcon({ icon }: { icon: GitActionIconName }) {39function GitActionItemIcon({ icon }: { icon: GitActionIconName }) {
33 if (icon === "commit") return <GitCommitIcon />;40 if (icon === "commit") return <GitCommitIcon />;
34 if (icon === "push") return <CloudUploadIcon />;41 if (icon === "push") return <CloudUploadIcon />;
35 return <GitHubIcon />;42 return <GitHubIcon />;
36}43}
3744
38function describeGitResult(result: GitRunStackedActionResult): string {45function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
39 const parts: string[] = [];46 if (quickAction.kind === "open_pr") return <GitHubIcon className="size-4" />;
4047 if (quickAction.kind === "run_pull") return <InfoIcon className="size-4" />;
41 if (result.commit.status === "created") {48 if (quickAction.kind === "run_action") {
42 parts.push(result.commit.subject ?? "Committed changes");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" />;
43 }52 }
4453 if (quickAction.label === "Commit") return <GitCommitIcon className="size-4" />;
45 if (result.push.status === "pushed") {54 return <InfoIcon className="size-4" />;
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 ];
117}55}
11856
119function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] {57function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] {
...@@ -135,34 +73,64 @@ function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null...@@ -135,34 +73,64 @@ function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null
13573
136export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {74export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {
137 const queryClient = useQueryClient();75 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
140 const { data: gitStatus = null, error: gitStatusError } = useQuery(79 const { data: gitStatus = null, error: gitStatusError } = useQuery(
141 gitStatusQueryOptions(api, gitCwd),80 gitStatusQueryOptions(api, gitCwd),
142 );81 );
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
144 const runImmediateGitActionMutation = useMutation({92 const runImmediateGitActionMutation = useMutation({
145 mutationFn: async (action: GitStackedAction) => {93 mutationFn: async ({
94 action,
95 commitMessage,
96 }: {
97 action: GitStackedAction;
98 commitMessage?: string;
99 }) => {
146 if (!api || !gitCwd) {100 if (!api || !gitCwd) {
147 throw new Error("Git action is unavailable.");101 throw new Error("Git action is unavailable.");
148 }102 }
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 });
150 },113 },
151 onSuccess: async () => {114 onSuccess: async () => {
152 await queryClient.invalidateQueries({ queryKey: ["git"] });115 await queryClient.invalidateQueries({ queryKey: ["git"] });
153 },116 },
154 });117 });
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;
159 const gitActionMenuItems = useMemo(127 const gitActionMenuItems = useMemo(
160 () => buildGitActionMenuItems(gitStatus, gitBaseDisabled),128 () => buildMenuItems(gitStatus, isGitActionRunning),
161 [gitBaseDisabled, gitStatus],129 [gitStatus, isGitActionRunning],
162 );130 );
163 const selectedGitActionItem = useMemo(131 const quickAction = useMemo(
164 () => gitActionMenuItems.find((item) => item.id === selectedGitActionId) ?? null,132 () => resolveQuickAction(gitStatus, isGitActionRunning),
165 [gitActionMenuItems, selectedGitActionId],133 [gitStatus, isGitActionRunning],
166 );134 );
167135
168 const refreshGitStatus = useCallback(async () => {136 const refreshGitStatus = useCallback(async () => {
...@@ -170,15 +138,71 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -170,15 +138,71 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
170 await queryClient.invalidateQueries({ queryKey: ["git"] });138 await queryClient.invalidateQueries({ queryKey: ["git"] });
171 }, [api, gitCwd, queryClient]);139 }, [api, gitCwd, queryClient]);
172140
173 const runGitActionImmediately = useCallback(141 const maybeConfirmPushToDefaultBranch = useCallback(
174 (action: GitStackedAction) => {142 async (action: GitStackedAction): Promise<boolean> => {
175 const promise = runImmediateGitActionMutation.mutateAsync(action);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 });
176 toastManager.promise(promise, {191 toastManager.promise(promise, {
177 loading: {192 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...",
179 },199 },
180 success: (result) => {200 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";
182 return {206 return {
183 title: "Done",207 title: "Done",
184 description: describeGitResult(result),208 description: describeGitResult(result),
...@@ -190,7 +214,15 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -190,7 +214,15 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
190 onClick: () => void api?.shell.openExternal(prUrl),214 onClick: () => void api?.shell.openExternal(prUrl),
191 },215 },
192 }216 }
193 : {}),217 : shouldOfferCreatePrCta
218 ? {
219 timeout: 10_000,
220 actionProps: {
221 children: "Create PR",
222 onClick: () => setActiveDialogAction("create_pr"),
223 },
224 }
225 : {}),
194 };226 };
195 },227 },
196 error: (err) => ({228 error: (err) => ({
...@@ -200,114 +232,217 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -200,114 +232,217 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
200 });232 });
201 void promise.catch(() => undefined);233 void promise.catch(() => undefined);
202 },234 },
203 [api, runImmediateGitActionMutation],235 [
236 api,
237 gitStatus?.openPr?.url,
238 maybeConfirmPushToDefaultBranch,
239 runImmediateGitActionMutation,
240 setActiveDialogAction,
241 ],
204 );242 );
205243
206 const openExistingPr = useCallback(() => {244 const runQuickAction = useCallback(() => {
207 if (!api) {245 if (quickAction.kind === "open_pr") {
208 toastManager.add({ type: "error", title: "Link opening is unavailable." });246 void openExistingPr();
209 return;247 return;
210 }248 }
211249 if (quickAction.kind === "run_pull") {
212 const prUrl = gitStatus?.openPr?.url ?? null;250 const promise = pullMutation.mutateAsync();
213 if (!prUrl) {251 toastManager.promise(promise, {
214 toastManager.add({ type: "error", title: "No open PR found." });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);
215 return;266 return;
216 }267 }
217268 if (quickAction.kind === "show_hint") {
218 void api.shell.openExternal(prUrl).catch((error) => {
219 toastManager.add({269 toastManager.add({
220 type: "error",270 type: "info",
221 title: "Unable to open PR link",271 title: quickAction.label,
222 description: error instanceof Error ? error.message : undefined,272 description: quickAction.hint,
223 });273 });
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();
231 return;274 return;
232 }275 }
233 if (selectedGitActionItem.action) {276 if (quickAction.action) {
234 runGitActionImmediately(selectedGitActionItem.action);277 void runGitActionWithToast({ action: quickAction.action });
235 }278 }
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(() => {295 const runDialogAction = useCallback(() => {
239 setSelectedGitActionId("commit");296 if (!activeDialogAction) return;
240 }, [gitCwd]);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(() => {312 const dialogTitle =
243 if (selectedGitActionItem) return;313 activeDialogAction === "commit"
244 const fallback = gitActionMenuItems.find((item) => !item.disabled) ?? gitActionMenuItems[0];314 ? "Commit changes"
245 if (fallback) {315 : activeDialogAction === "push"
246 setSelectedGitActionId(fallback.id);316 ? "Push branch"
247 }317 : "Create pull request";
248 }, [gitActionMenuItems, selectedGitActionItem]);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
250 if (!gitCwd) return null;325 if (!gitCwd) return null;
251326
252 return (327 return (
253 <Group aria-label="Git actions">328 <>
254 <Button329 <Group aria-label="Git actions">
255 variant="outline"330 <Button
256 size="xs"331 variant="outline"
257 disabled={!selectedGitActionItem || selectedGitActionItem.disabled || isGitActionRunning}332 size="xs"
258 onClick={runSelectedAction}333 disabled={isGitActionRunning || quickAction.disabled}
259 >334 onClick={runQuickAction}
260 {isGitActionRunning ? "Running..." : selectedGitActionItem?.label ?? "Git actions"}335 >
261 </Button>336 <GitQuickActionIcon quickAction={quickAction} />
262 <GroupSeparator />337 {quickAction.label}
263 <Menu338 </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}
264 onOpenChange={(open) => {386 onOpenChange={(open) => {
265 if (open) void refreshGitStatus();387 if (!open) {
388 setActiveDialogAction(null);
389 setDialogCommitMessage("");
390 }
266 }}391 }}
267 >392 >
268 <MenuTrigger393 <DialogPopup>
269 render={<Button aria-label="Git action options" size="icon-xs" variant="outline" />}394 <DialogHeader>
270 disabled={!gitStatus || isGitActionRunning}395 <DialogTitle>{dialogTitle}</DialogTitle>
271 >396 <DialogDescription>{dialogDescription}</DialogDescription>
272 <ChevronDownIcon aria-hidden="true" className="size-4" />397 </DialogHeader>
273 </MenuTrigger>398 <DialogPanel className="space-y-4">
274 <MenuPopup align="end" sideOffset={4}>399 <div className="rounded-lg border border-input bg-muted/40 p-3 text-xs">
275 {gitActionMenuItems.map((item) => (400 <p>
276 <MenuItem401 <span className="font-medium">Branch:</span>{" "}
277 key={`${item.id}-${item.label}`}402 {gitStatus?.branch ?? "(detached HEAD)"}
278 disabled={item.disabled}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"
279 onClick={() => {429 onClick={() => {
280 setSelectedGitActionId(item.id);430 setActiveDialogAction(null);
281 if (item.kind === "open_pr") {431 setDialogCommitMessage("");
282 openExistingPr();
283 } else if (item.action) {
284 runGitActionImmediately(item.action);
285 }
286 }}432 }}
287 >433 >
288 <GitActionItemIcon icon={item.icon} />434 Cancel
289 {item.label}435 </Button>
290 </MenuItem>436 <Button size="sm" onClick={runDialogAction}>
291 ))}437 {activeDialogAction === "commit"
292 {gitStatus?.branch === null && (438 ? "Commit"
293 <p className="px-2 py-1.5 text-xs text-warning">439 : activeDialogAction === "push"
294 Detached HEAD: push and PR are unavailable.440 ? "Push"
295 </p>441 : "Create PR"}
296 )}442 </Button>
297 {gitStatus &&443 </DialogFooter>
298 gitStatus.branch !== null &&444 </DialogPopup>
299 !gitStatus.hasWorkingTreeChanges &&445 </Dialog>
300 gitStatus.aheadCount === 0 &&446 </>
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>
312 );447 );
313}448}
apps/web/src/wsNativeApi.ts+1
...@@ -118,6 +118,7 @@ export function createWsNativeApi(): NativeApi {...@@ -118,6 +118,7 @@ export function createWsNativeApi(): NativeApi {
118 },118 },
119 },119 },
120 git: {120 git: {
121 pull: (input) => transport.request(WS_METHODS.gitPull, input),
121 status: (input) => transport.request(WS_METHODS.gitStatus, input),122 status: (input) => transport.request(WS_METHODS.gitStatus, input),
122 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),123 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),
123 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),124 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
packages/contracts/src/git.test.ts+17
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import {3import {
4 gitPullInputSchema,
5 gitPullResultSchema,
4 gitRunStackedActionInputSchema,6 gitRunStackedActionInputSchema,
5 gitRunStackedActionResultSchema,7 gitRunStackedActionResultSchema,
6 gitStackedActionSchema,8 gitStackedActionSchema,
...@@ -14,6 +16,11 @@ describe("git contracts", () => {...@@ -14,6 +16,11 @@ describe("git contracts", () => {
14 expect(parsed.cwd).toBe("/tmp/repo");16 expect(parsed.cwd).toBe("/tmp/repo");
15 });17 });
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
17 it("parses git status result", () => {24 it("parses git status result", () => {
18 const parsed = gitStatusResultSchema.parse({25 const parsed = gitStatusResultSchema.parse({
19 branch: "feature/git-actions",26 branch: "feature/git-actions",
...@@ -79,4 +86,14 @@ describe("git contracts", () => {...@@ -79,4 +86,14 @@ describe("git contracts", () => {
79 expect(parsed.commit.status).toBe("created");86 expect(parsed.commit.status).toBe("created");
80 expect(parsed.pr.number).toBe(123);87 expect(parsed.pr.number).toBe(123);
81 });88 });
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 });
82});99});
packages/contracts/src/git.ts+13
...@@ -68,6 +68,10 @@ export const gitStatusInputSchema = z.object({...@@ -68,6 +68,10 @@ export const gitStatusInputSchema = z.object({
68 cwd: z.string().trim().min(1),68 cwd: z.string().trim().min(1),
69});69});
7070
71export const gitPullInputSchema = z.object({
72 cwd: z.string().trim().min(1),
73});
74
71export const gitStatusResultSchema = z.object({75export const gitStatusResultSchema = z.object({
72 branch: z.string().min(1).nullable(),76 branch: z.string().min(1).nullable(),
73 hasWorkingTreeChanges: z.boolean(),77 hasWorkingTreeChanges: z.boolean(),
...@@ -133,7 +137,16 @@ export const gitRunStackedActionResultSchema = z.object({...@@ -133,7 +137,16 @@ export const gitRunStackedActionResultSchema = z.object({
133});137});
134138
135export type GitStatusInput = z.input<typeof gitStatusInputSchema>;139export type GitStatusInput = z.input<typeof gitStatusInputSchema>;
140export type GitPullInput = z.input<typeof gitPullInputSchema>;
136export type GitStatusResult = z.infer<typeof gitStatusResultSchema>;141export type GitStatusResult = z.infer<typeof gitStatusResultSchema>;
137export type GitStackedAction = z.infer<typeof gitStackedActionSchema>;142export type GitStackedAction = z.infer<typeof gitStackedActionSchema>;
138export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>;143export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>;
139export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>;144export 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 {...@@ -7,6 +7,8 @@ import type {
7 GitInitInput,7 GitInitInput,
8 GitListBranchesInput,8 GitListBranchesInput,
9 GitListBranchesResult,9 GitListBranchesResult,
10 GitPullInput,
11 GitPullResult,
10 GitRemoveWorktreeInput,12 GitRemoveWorktreeInput,
11 GitRunStackedActionInput,13 GitRunStackedActionInput,
12 GitRunStackedActionResult,14 GitRunStackedActionResult,
...@@ -101,6 +103,7 @@ export interface NativeApi {...@@ -101,6 +103,7 @@ export interface NativeApi {
101 checkout: (input: GitCheckoutInput) => Promise<void>;103 checkout: (input: GitCheckoutInput) => Promise<void>;
102 init: (input: GitInitInput) => Promise<void>;104 init: (input: GitInitInput) => Promise<void>;
103 // Stacked action API105 // Stacked action API
106 pull: (input: GitPullInput) => Promise<GitPullResult>;
104 status: (input: GitStatusInput) => Promise<GitStatusResult>;107 status: (input: GitStatusInput) => Promise<GitStatusResult>;
105 runStackedAction: (108 runStackedAction: (
106 input: GitRunStackedActionInput,109 input: GitRunStackedActionInput,
packages/contracts/src/ws.ts+1
...@@ -20,6 +20,7 @@ export const WS_METHODS = {...@@ -20,6 +20,7 @@ export const WS_METHODS = {
20 shellOpenInEditor: "shell.openInEditor",20 shellOpenInEditor: "shell.openInEditor",
2121
22 // Git methods22 // Git methods
23 gitPull: "git.pull",
23 gitStatus: "git.status",24 gitStatus: "git.status",
24 gitRunStackedAction: "git.runStackedAction",25 gitRunStackedAction: "git.runStackedAction",
25 gitListBranches: "git.listBranches",26 gitListBranches: "git.listBranches",