| author | |
| committer | |
| log | 5a30594009cc1170cd181649899c86b5452eff15 |
| tree | 646dcc7c78efa3b07c14ae6c5b7427db139e134c |
| parent | 02ca9324c5f67f82fc518edeb9c8b76dbfade819 |
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 | }); |
| 606 | 607 | ||
| 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"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 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 | } |
| 293 | 297 | ||
| 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 | } |
| 513 | 540 | ||
| 541 | export async function pullGitBranch(raw: GitPullInput): Promise<GitPullResult> { | ||
| 542 | const input = gitPullInputSchema.parse(raw); | ||
| 543 | return defaultGitCoreService.pullCurrentBranch(input.cwd); | ||
| 544 | } | ||
| 545 | |||
| 514 | export async function createGitWorktree( | 546 | export 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 | }); |
| 549 | 554 | ||
| 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"; |
| 32 | import { TerminalManager } from "./terminalManager"; | 33 | import { 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); |
| 365 | 366 | ||
| 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 @@ | ||
| 1 | import type { GitStatusResult } from "@t3tools/contracts"; | ||
| 2 | import { assert, describe, it } from "vitest"; | ||
| 3 | import { | ||
| 4 | buildMenuItems, | ||
| 5 | requiresDefaultBranchConfirmation, | ||
| 6 | resolveQuickAction, | ||
| 7 | } from "./GitActionsControl.logic"; | ||
| 8 | |||
| 9 | function 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 | |||
| 21 | describe("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 | |||
| 79 | describe("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 | |||
| 121 | describe("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 | |||
| 138 | describe("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 | |||
| 198 | describe("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 | |||
| 239 | describe("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 | |||
| 279 | describe("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 | |||
| 316 | describe("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 | |||
| 353 | describe("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 | |||
| 390 | describe("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 | |||
| 430 | describe("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 | |||
| 517 | describe("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 @@ | ||
| 1 | import type { GitRunStackedActionResult, GitStackedAction, GitStatusResult } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export type GitActionIconName = "commit" | "push" | "pr"; | ||
| 4 | |||
| 5 | export type GitDialogAction = "commit" | "push" | "create_pr"; | ||
| 6 | |||
| 7 | export 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 | |||
| 16 | export 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 | |||
| 24 | export 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 | |||
| 48 | export 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 | |||
| 111 | export 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 | |||
| 190 | export 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 7 | import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 5 | import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 8 | import { useCallback, useEffect, useMemo, useState } from "react"; | 6 | import { useCallback, useMemo, useState } from "react"; |
| 9 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon } from "lucide-react"; | 7 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; |
| 10 | import { GitHubIcon } from "./Icons"; | 8 | import { GitHubIcon } from "./Icons"; |
| 9 | import { | ||
| 10 | buildMenuItems, | ||
| 11 | type GitActionIconName, | ||
| 12 | type GitActionMenuItem, | ||
| 13 | type GitDialogAction, | ||
| 14 | type GitQuickAction, | ||
| 15 | describeGitResult, | ||
| 16 | requiresDefaultBranchConfirmation, | ||
| 17 | resolveQuickAction, | ||
| 18 | } from "./GitActionsControl.logic"; | ||
| 11 | import { Button } from "~/components/ui/button"; | 19 | import { Button } from "~/components/ui/button"; |
| 20 | import { | ||
| 21 | Dialog, | ||
| 22 | DialogDescription, | ||
| 23 | DialogFooter, | ||
| 24 | DialogHeader, | ||
| 25 | DialogPanel, | ||
| 26 | DialogPopup, | ||
| 27 | DialogTitle, | ||
| 28 | } from "~/components/ui/dialog"; | ||
| 12 | import { Group, GroupSeparator } from "~/components/ui/group"; | 29 | import { Group, GroupSeparator } from "~/components/ui/group"; |
| 13 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; | 30 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; |
| 31 | import { Textarea } from "~/components/ui/textarea"; | ||
| 14 | import { toastManager } from "~/components/ui/toast"; | 32 | import { toastManager } from "~/components/ui/toast"; |
| 15 | 33 | ||
| 16 | interface GitActionsControlProps { | 34 | interface GitActionsControlProps { |
| ... | @@ -18,102 +36,22 @@ interface GitActionsControlProps { | ... | @@ -18,102 +36,22 @@ interface GitActionsControlProps { |
| 18 | gitCwd: string | null; | 36 | gitCwd: string | null; |
| 19 | } | 37 | } |
| 20 | 38 | ||
| 21 | type GitActionIconName = "commit" | "push" | "pr"; | ||
| 22 | |||
| 23 | interface 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 | |||
| 32 | function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { | 39 | function 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 | } |
| 37 | 44 | ||
| 38 | function describeGitResult(result: GitRunStackedActionResult): string { | 45 | function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { |
| 39 | const parts: string[] = []; | 46 | if (quickAction.kind === "open_pr") return <GitHubIcon className="size-4" />; |
| 40 | 47 | 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 | } |
| 44 | 53 | 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 | |||
| 62 | function 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 | } |
| 118 | 56 | ||
| 119 | function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] { | 57 | function 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 |
| 135 | 73 | ||
| 136 | export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { | 74 | export 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(""); | ||
| 139 | 78 | ||
| 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 | ); |
| 143 | 82 | ||
| 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 | }); |
| 155 | 118 | ||
| 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]); | ||
| 157 | 126 | ||
| 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 | ); |
| 167 | 135 | ||
| 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]); |
| 172 | 140 | ||
| 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 | ); |
| 205 | 243 | ||
| 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 | } |
| 211 | 249 | 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 | } |
| 217 | 268 | 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 | ); | ||
| 237 | 294 | ||
| 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]); | ||
| 241 | 311 | ||
| 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."; | ||
| 249 | 324 | ||
| 250 | if (!gitCwd) return null; | 325 | if (!gitCwd) return null; |
| 251 | 326 | ||
| 252 | return ( | 327 | return ( |
| 253 | <Group aria-label="Git actions"> | 328 | <> |
| 254 | <Button | 329 | <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 | <Menu | 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} | ||
| 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 | <MenuTrigger | 393 | <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 | <MenuItem | 401 | <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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | }); |
| 16 | 18 | ||
| 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 | }); |
| 70 | 70 | ||
| 71 | export const gitPullInputSchema = z.object({ | ||
| 72 | cwd: z.string().trim().min(1), | ||
| 73 | }); | ||
| 74 | |||
| 71 | export const gitStatusResultSchema = z.object({ | 75 | export 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 | }); |
| 134 | 138 | ||
| 135 | export type GitStatusInput = z.input<typeof gitStatusInputSchema>; | 139 | export type GitStatusInput = z.input<typeof gitStatusInputSchema>; |
| 140 | export type GitPullInput = z.input<typeof gitPullInputSchema>; | ||
| 136 | export type GitStatusResult = z.infer<typeof gitStatusResultSchema>; | 141 | export type GitStatusResult = z.infer<typeof gitStatusResultSchema>; |
| 137 | export type GitStackedAction = z.infer<typeof gitStackedActionSchema>; | 142 | export type GitStackedAction = z.infer<typeof gitStackedActionSchema>; |
| 138 | export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>; | 143 | export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>; |
| 139 | export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>; | 144 | export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>; |
| 145 | |||
| 146 | export 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 | |||
| 152 | export 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 API | 105 | // 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", |
| 21 | 21 | ||
| 22 | // Git methods | 22 | // 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", |