| 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 | 11 | createGitWorktree, |
| 12 | 12 | initGitRepo, |
| 13 | 13 | listGitBranches, |
| 14 | pullGitBranch, | |
| 14 | 15 | removeGitWorktree, |
| 15 | 16 | runTerminalCommand, |
| 16 | 17 | } from "./git"; |
| ... | ... | @@ -604,6 +605,42 @@ describe("git integration", () => { |
| 604 | 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 | 644 | it("lists branches when recency lookup fails", async () => { |
| 608 | 645 | await using tmp = await makeTmpDir(); |
| 609 | 646 | await initRepoWithCommit(tmp.path); |
apps/server/src/git.ts+32| ... | ... | @@ -3,6 +3,8 @@ import os from "node:os"; |
| 3 | 3 | import path from "node:path"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | gitPullInputSchema, | |
| 7 | gitPullResultSchema, | |
| 6 | 8 | gitStatusInputSchema, |
| 7 | 9 | gitStatusResultSchema, |
| 8 | 10 | type GitCheckoutInput, |
| ... | ... | @@ -12,6 +14,8 @@ import { |
| 12 | 14 | type GitInitInput, |
| 13 | 15 | type GitListBranchesInput, |
| 14 | 16 | type GitListBranchesResult, |
| 17 | type GitPullInput, | |
| 18 | type GitPullResult, | |
| 15 | 19 | type GitRemoveWorktreeInput, |
| 16 | 20 | type GitStatusInput, |
| 17 | 21 | type GitStatusResult, |
| ... | ... | @@ -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 | 321 | async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> { |
| 295 | 322 | const range = `${baseBranch}..HEAD`; |
| 296 | 323 | const [commitSummary, diffSummary, diffPatch] = await Promise.all([ |
| ... | ... | @@ -511,6 +538,11 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL |
| 511 | 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 | 546 | export async function createGitWorktree( |
| 515 | 547 | input: GitCreateWorktreeInput, |
| 516 | 548 | ): Promise<GitCreateWorktreeResult> { |
apps/server/src/wsServer.test.ts+5| ... | ... | @@ -545,6 +545,11 @@ describe("WebSocket Server", () => { |
| 545 | 545 | }); |
| 546 | 546 | expect(afterInit.error).toBeUndefined(); |
| 547 | 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 | 555 | it("responds to git.status via the git manager", async () => { |
apps/server/src/wsServer.ts+4| ... | ... | @@ -27,6 +27,7 @@ import { |
| 27 | 27 | createGitWorktree, |
| 28 | 28 | initGitRepo, |
| 29 | 29 | listGitBranches, |
| 30 | pullGitBranch, | |
| 30 | 31 | removeGitWorktree, |
| 31 | 32 | } from "./git"; |
| 32 | 33 | import { TerminalManager } from "./terminalManager"; |
| ... | ... | @@ -363,6 +364,9 @@ export function createServer(options: ServerOptions) { |
| 363 | 364 | case WS_METHODS.gitStatus: |
| 364 | 365 | return gitManager.status(request.params as never); |
| 365 | 366 | |
| 367 | case WS_METHODS.gitPull: | |
| 368 | return pullGitBranch(request.params as never); | |
| 369 | ||
| 366 | 370 | case WS_METHODS.gitRunStackedAction: |
| 367 | 371 | return gitManager.runStackedAction(request.params as never); |
| 368 | 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 | 1 | import { |
| 2 | type GitRunStackedActionResult, | |
| 3 | 2 | type GitStackedAction, |
| 4 | type GitStatusResult, | |
| 5 | 3 | type NativeApi, |
| 6 | 4 | } from "@t3tools/contracts"; |
| 7 | 5 | import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 8 | import { useCallback, useEffect, useMemo, useState } from "react"; | |
| 9 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon } from "lucide-react"; | |
| 6 | import { useCallback, useMemo, useState } from "react"; | |
| 7 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; | |
| 10 | 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 | 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 | 29 | import { Group, GroupSeparator } from "~/components/ui/group"; |
| 13 | 30 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; |
| 31 | import { Textarea } from "~/components/ui/textarea"; | |
| 14 | 32 | import { toastManager } from "~/components/ui/toast"; |
| 15 | 33 | |
| 16 | 34 | interface GitActionsControlProps { |
| ... | ... | @@ -18,102 +36,22 @@ interface GitActionsControlProps { |
| 18 | 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 | 39 | function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { |
| 33 | 40 | if (icon === "commit") return <GitCommitIcon />; |
| 34 | 41 | if (icon === "push") return <CloudUploadIcon />; |
| 35 | 42 | return <GitHubIcon />; |
| 36 | 43 | } |
| 37 | 44 | |
| 38 | function describeGitResult(result: GitRunStackedActionResult): string { | |
| 39 | const parts: string[] = []; | |
| 40 | ||
| 41 | if (result.commit.status === "created") { | |
| 42 | parts.push(result.commit.subject ?? "Committed changes"); | |
| 45 | function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { | |
| 46 | if (quickAction.kind === "open_pr") return <GitHubIcon className="size-4" />; | |
| 47 | if (quickAction.kind === "run_pull") return <InfoIcon className="size-4" />; | |
| 48 | if (quickAction.kind === "run_action") { | |
| 49 | if (quickAction.action === "commit") return <GitCommitIcon className="size-4" />; | |
| 50 | if (quickAction.action === "commit_push") return <CloudUploadIcon className="size-4" />; | |
| 51 | return <GitHubIcon className="size-4" />; | |
| 43 | 52 | } |
| 44 | ||
| 45 | if (result.push.status === "pushed") { | |
| 46 | parts.push( | |
| 47 | result.push.upstreamBranch | |
| 48 | ? `Pushed to ${result.push.upstreamBranch}` | |
| 49 | : "Pushed", | |
| 50 | ); | |
| 51 | } | |
| 52 | ||
| 53 | if (result.pr.status === "created") { | |
| 54 | parts.push(result.pr.number ? `Created PR #${result.pr.number}` : "Created PR"); | |
| 55 | } else if (result.pr.status === "opened_existing") { | |
| 56 | parts.push(result.pr.number ? `PR #${result.pr.number}` : "Opened existing PR"); | |
| 57 | } | |
| 58 | ||
| 59 | return parts.join(" · ") || "No changes needed."; | |
| 60 | } | |
| 61 | ||
| 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 | ]; | |
| 53 | if (quickAction.label === "Commit") return <GitCommitIcon className="size-4" />; | |
| 54 | return <InfoIcon className="size-4" />; | |
| 117 | 55 | } |
| 118 | 56 | |
| 119 | 57 | function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] { |
| ... | ... | @@ -135,34 +73,64 @@ function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null |
| 135 | 73 | |
| 136 | 74 | export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { |
| 137 | 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 | 79 | const { data: gitStatus = null, error: gitStatusError } = useQuery( |
| 141 | 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 | 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 | 100 | if (!api || !gitCwd) { |
| 147 | 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 | 114 | onSuccess: async () => { |
| 152 | 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 | 127 | const gitActionMenuItems = useMemo( |
| 160 | () => buildGitActionMenuItems(gitStatus, gitBaseDisabled), | |
| 161 | [gitBaseDisabled, gitStatus], | |
| 128 | () => buildMenuItems(gitStatus, isGitActionRunning), | |
| 129 | [gitStatus, isGitActionRunning], | |
| 162 | 130 | ); |
| 163 | const selectedGitActionItem = useMemo( | |
| 164 | () => gitActionMenuItems.find((item) => item.id === selectedGitActionId) ?? null, | |
| 165 | [gitActionMenuItems, selectedGitActionId], | |
| 131 | const quickAction = useMemo( | |
| 132 | () => resolveQuickAction(gitStatus, isGitActionRunning), | |
| 133 | [gitStatus, isGitActionRunning], | |
| 166 | 134 | ); |
| 167 | 135 | |
| 168 | 136 | const refreshGitStatus = useCallback(async () => { |
| ... | ... | @@ -170,15 +138,71 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 170 | 138 | await queryClient.invalidateQueries({ queryKey: ["git"] }); |
| 171 | 139 | }, [api, gitCwd, queryClient]); |
| 172 | 140 | |
| 173 | const runGitActionImmediately = useCallback( | |
| 174 | (action: GitStackedAction) => { | |
| 175 | const promise = runImmediateGitActionMutation.mutateAsync(action); | |
| 141 | const maybeConfirmPushToDefaultBranch = useCallback( | |
| 142 | async (action: GitStackedAction): Promise<boolean> => { | |
| 143 | if (!api) return false; | |
| 144 | if (!requiresDefaultBranchConfirmation(action, isDefaultBranch) || !gitStatus?.branch) { | |
| 145 | return true; | |
| 146 | } | |
| 147 | return api.dialogs.confirm( | |
| 148 | `Push to default branch "${gitStatus.branch}"? This will update the shared base branch.`, | |
| 149 | ); | |
| 150 | }, | |
| 151 | [api, gitStatus?.branch, isDefaultBranch], | |
| 152 | ); | |
| 153 | ||
| 154 | const openExistingPr = useCallback(async () => { | |
| 155 | if (!api) { | |
| 156 | toastManager.add({ type: "error", title: "Link opening is unavailable." }); | |
| 157 | return; | |
| 158 | } | |
| 159 | const prUrl = gitStatus?.openPr?.url ?? null; | |
| 160 | if (!prUrl) { | |
| 161 | toastManager.add({ type: "error", title: "No open PR found." }); | |
| 162 | return; | |
| 163 | } | |
| 164 | const promise = api.shell.openExternal(prUrl); | |
| 165 | toastManager.promise(promise, { | |
| 166 | loading: { title: "Opening PR..." }, | |
| 167 | success: { title: "PR opened" }, | |
| 168 | error: (err) => ({ | |
| 169 | title: "Unable to open PR link", | |
| 170 | description: err instanceof Error ? err.message : "An error occurred.", | |
| 171 | }), | |
| 172 | }); | |
| 173 | void promise.catch(() => undefined); | |
| 174 | }, [api, gitStatus?.openPr?.url]); | |
| 175 | ||
| 176 | const runGitActionWithToast = useCallback( | |
| 177 | async ({ | |
| 178 | action, | |
| 179 | commitMessage, | |
| 180 | }: { | |
| 181 | action: GitStackedAction; | |
| 182 | commitMessage?: string; | |
| 183 | }) => { | |
| 184 | const confirmed = await maybeConfirmPushToDefaultBranch(action); | |
| 185 | if (!confirmed) return; | |
| 186 | ||
| 187 | const promise = runImmediateGitActionMutation.mutateAsync({ | |
| 188 | action, | |
| 189 | ...(commitMessage ? { commitMessage } : {}), | |
| 190 | }); | |
| 176 | 191 | toastManager.promise(promise, { |
| 177 | 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 | 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 | 206 | return { |
| 183 | 207 | title: "Done", |
| 184 | 208 | description: describeGitResult(result), |
| ... | ... | @@ -190,7 +214,15 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 190 | 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 | 228 | error: (err) => ({ |
| ... | ... | @@ -200,114 +232,217 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 200 | 232 | }); |
| 201 | 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(() => { | |
| 207 | if (!api) { | |
| 208 | toastManager.add({ type: "error", title: "Link opening is unavailable." }); | |
| 244 | const runQuickAction = useCallback(() => { | |
| 245 | if (quickAction.kind === "open_pr") { | |
| 246 | void openExistingPr(); | |
| 209 | 247 | return; |
| 210 | 248 | } |
| 211 | ||
| 212 | const prUrl = gitStatus?.openPr?.url ?? null; | |
| 213 | if (!prUrl) { | |
| 214 | toastManager.add({ type: "error", title: "No open PR found." }); | |
| 249 | if (quickAction.kind === "run_pull") { | |
| 250 | const promise = pullMutation.mutateAsync(); | |
| 251 | toastManager.promise(promise, { | |
| 252 | loading: { title: "Pulling..." }, | |
| 253 | success: (result) => ({ | |
| 254 | title: result.status === "pulled" ? "Pulled" : "Already up to date", | |
| 255 | description: | |
| 256 | result.status === "pulled" | |
| 257 | ? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}` | |
| 258 | : `${result.branch} is already synchronized.`, | |
| 259 | }), | |
| 260 | error: (err) => ({ | |
| 261 | title: "Pull failed", | |
| 262 | description: err instanceof Error ? err.message : "An error occurred.", | |
| 263 | }), | |
| 264 | }); | |
| 265 | void promise.catch(() => undefined); | |
| 215 | 266 | return; |
| 216 | 267 | } |
| 217 | ||
| 218 | void api.shell.openExternal(prUrl).catch((error) => { | |
| 268 | if (quickAction.kind === "show_hint") { | |
| 219 | 269 | toastManager.add({ |
| 220 | type: "error", | |
| 221 | title: "Unable to open PR link", | |
| 222 | description: error instanceof Error ? error.message : undefined, | |
| 270 | type: "info", | |
| 271 | title: quickAction.label, | |
| 272 | description: quickAction.hint, | |
| 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 | 274 | return; |
| 232 | 275 | } |
| 233 | if (selectedGitActionItem.action) { | |
| 234 | runGitActionImmediately(selectedGitActionItem.action); | |
| 276 | if (quickAction.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(() => { | |
| 239 | setSelectedGitActionId("commit"); | |
| 240 | }, [gitCwd]); | |
| 295 | const runDialogAction = useCallback(() => { | |
| 296 | if (!activeDialogAction) return; | |
| 297 | const action: GitStackedAction = | |
| 298 | activeDialogAction === "commit" | |
| 299 | ? "commit" | |
| 300 | : activeDialogAction === "push" | |
| 301 | ? "commit_push" | |
| 302 | : "commit_push_pr"; | |
| 303 | const commitMessage = | |
| 304 | activeDialogAction === "commit" ? dialogCommitMessage.trim() : ""; | |
| 305 | setActiveDialogAction(null); | |
| 306 | void runGitActionWithToast({ | |
| 307 | action, | |
| 308 | ...(commitMessage ? { commitMessage } : {}), | |
| 309 | }); | |
| 310 | }, [activeDialogAction, dialogCommitMessage, runGitActionWithToast]); | |
| 241 | 311 | |
| 242 | useEffect(() => { | |
| 243 | if (selectedGitActionItem) return; | |
| 244 | const fallback = gitActionMenuItems.find((item) => !item.disabled) ?? gitActionMenuItems[0]; | |
| 245 | if (fallback) { | |
| 246 | setSelectedGitActionId(fallback.id); | |
| 247 | } | |
| 248 | }, [gitActionMenuItems, selectedGitActionItem]); | |
| 312 | const dialogTitle = | |
| 313 | activeDialogAction === "commit" | |
| 314 | ? "Commit changes" | |
| 315 | : activeDialogAction === "push" | |
| 316 | ? "Push branch" | |
| 317 | : "Create pull request"; | |
| 318 | const dialogDescription = | |
| 319 | activeDialogAction === "commit" | |
| 320 | ? "Review and confirm your commit. Leave the message blank to auto-generate one." | |
| 321 | : activeDialogAction === "push" | |
| 322 | ? "Push this branch now. If this is the default branch, you'll be asked to confirm." | |
| 323 | : "Create a pull request using generated title/body content."; | |
| 249 | 324 | |
| 250 | 325 | if (!gitCwd) return null; |
| 251 | 326 | |
| 252 | 327 | return ( |
| 253 | <Group aria-label="Git actions"> | |
| 254 | <Button | |
| 255 | variant="outline" | |
| 256 | size="xs" | |
| 257 | disabled={!selectedGitActionItem || selectedGitActionItem.disabled || isGitActionRunning} | |
| 258 | onClick={runSelectedAction} | |
| 259 | > | |
| 260 | {isGitActionRunning ? "Running..." : selectedGitActionItem?.label ?? "Git actions"} | |
| 261 | </Button> | |
| 262 | <GroupSeparator /> | |
| 263 | <Menu | |
| 328 | <> | |
| 329 | <Group aria-label="Git actions"> | |
| 330 | <Button | |
| 331 | variant="outline" | |
| 332 | size="xs" | |
| 333 | disabled={isGitActionRunning || quickAction.disabled} | |
| 334 | onClick={runQuickAction} | |
| 335 | > | |
| 336 | <GitQuickActionIcon quickAction={quickAction} /> | |
| 337 | {quickAction.label} | |
| 338 | </Button> | |
| 339 | <GroupSeparator /> | |
| 340 | <Menu | |
| 341 | onOpenChange={(open) => { | |
| 342 | if (open) void refreshGitStatus(); | |
| 343 | }} | |
| 344 | > | |
| 345 | <MenuTrigger | |
| 346 | render={<Button aria-label="Git action options" size="icon-xs" variant="outline" />} | |
| 347 | disabled={isGitActionRunning} | |
| 348 | > | |
| 349 | <ChevronDownIcon aria-hidden="true" className="size-4" /> | |
| 350 | </MenuTrigger> | |
| 351 | <MenuPopup align="end" sideOffset={4}> | |
| 352 | {gitActionMenuItems.map((item) => ( | |
| 353 | <MenuItem | |
| 354 | key={`${item.id}-${item.label}`} | |
| 355 | disabled={item.disabled} | |
| 356 | onClick={() => { | |
| 357 | openDialogForMenuItem(item); | |
| 358 | }} | |
| 359 | > | |
| 360 | <GitActionItemIcon icon={item.icon} /> | |
| 361 | {item.label} | |
| 362 | </MenuItem> | |
| 363 | ))} | |
| 364 | {gitStatus?.branch === null && ( | |
| 365 | <p className="px-2 py-1.5 text-xs text-warning"> | |
| 366 | Detached HEAD: create and checkout a branch to enable push and PR actions. | |
| 367 | </p> | |
| 368 | )} | |
| 369 | {gitStatus && | |
| 370 | gitStatus.branch !== null && | |
| 371 | !gitStatus.hasWorkingTreeChanges && | |
| 372 | gitStatus.behindCount > 0 && ( | |
| 373 | <p className="px-2 py-1.5 text-xs text-warning"> | |
| 374 | Behind upstream. Pull/rebase first. | |
| 375 | </p> | |
| 376 | )} | |
| 377 | {gitStatusError && ( | |
| 378 | <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p> | |
| 379 | )} | |
| 380 | </MenuPopup> | |
| 381 | </Menu> | |
| 382 | </Group> | |
| 383 | ||
| 384 | <Dialog | |
| 385 | open={activeDialogAction !== null} | |
| 264 | 386 | onOpenChange={(open) => { |
| 265 | if (open) void refreshGitStatus(); | |
| 387 | if (!open) { | |
| 388 | setActiveDialogAction(null); | |
| 389 | setDialogCommitMessage(""); | |
| 390 | } | |
| 266 | 391 | }} |
| 267 | 392 | > |
| 268 | <MenuTrigger | |
| 269 | render={<Button aria-label="Git action options" size="icon-xs" variant="outline" />} | |
| 270 | disabled={!gitStatus || isGitActionRunning} | |
| 271 | > | |
| 272 | <ChevronDownIcon aria-hidden="true" className="size-4" /> | |
| 273 | </MenuTrigger> | |
| 274 | <MenuPopup align="end" sideOffset={4}> | |
| 275 | {gitActionMenuItems.map((item) => ( | |
| 276 | <MenuItem | |
| 277 | key={`${item.id}-${item.label}`} | |
| 278 | disabled={item.disabled} | |
| 393 | <DialogPopup> | |
| 394 | <DialogHeader> | |
| 395 | <DialogTitle>{dialogTitle}</DialogTitle> | |
| 396 | <DialogDescription>{dialogDescription}</DialogDescription> | |
| 397 | </DialogHeader> | |
| 398 | <DialogPanel className="space-y-4"> | |
| 399 | <div className="rounded-lg border border-input bg-muted/40 p-3 text-xs"> | |
| 400 | <p> | |
| 401 | <span className="font-medium">Branch:</span>{" "} | |
| 402 | {gitStatus?.branch ?? "(detached HEAD)"} | |
| 403 | </p> | |
| 404 | <p> | |
| 405 | <span className="font-medium">Working tree:</span>{" "} | |
| 406 | {gitStatus?.hasWorkingTreeChanges ? "Has changes" : "Clean"} | |
| 407 | </p> | |
| 408 | <p> | |
| 409 | <span className="font-medium">Ahead/behind:</span>{" "} | |
| 410 | {gitStatus ? `${gitStatus.aheadCount}/${gitStatus.behindCount}` : "unknown"} | |
| 411 | </p> | |
| 412 | </div> | |
| 413 | {activeDialogAction === "commit" && ( | |
| 414 | <div className="space-y-1"> | |
| 415 | <p className="text-xs font-medium">Commit message (optional)</p> | |
| 416 | <Textarea | |
| 417 | value={dialogCommitMessage} | |
| 418 | onChange={(event) => setDialogCommitMessage(event.target.value)} | |
| 419 | placeholder="Leave empty to auto-generate" | |
| 420 | size="sm" | |
| 421 | /> | |
| 422 | </div> | |
| 423 | )} | |
| 424 | </DialogPanel> | |
| 425 | <DialogFooter> | |
| 426 | <Button | |
| 427 | variant="outline" | |
| 428 | size="sm" | |
| 279 | 429 | onClick={() => { |
| 280 | setSelectedGitActionId(item.id); | |
| 281 | if (item.kind === "open_pr") { | |
| 282 | openExistingPr(); | |
| 283 | } else if (item.action) { | |
| 284 | runGitActionImmediately(item.action); | |
| 285 | } | |
| 430 | setActiveDialogAction(null); | |
| 431 | setDialogCommitMessage(""); | |
| 286 | 432 | }} |
| 287 | 433 | > |
| 288 | <GitActionItemIcon icon={item.icon} /> | |
| 289 | {item.label} | |
| 290 | </MenuItem> | |
| 291 | ))} | |
| 292 | {gitStatus?.branch === null && ( | |
| 293 | <p className="px-2 py-1.5 text-xs text-warning"> | |
| 294 | Detached HEAD: push and PR are unavailable. | |
| 295 | </p> | |
| 296 | )} | |
| 297 | {gitStatus && | |
| 298 | gitStatus.branch !== null && | |
| 299 | !gitStatus.hasWorkingTreeChanges && | |
| 300 | gitStatus.aheadCount === 0 && | |
| 301 | gitStatus.behindCount > 0 && ( | |
| 302 | <p className="px-2 py-1.5 text-xs text-warning"> | |
| 303 | Behind upstream. Pull/rebase first. | |
| 304 | </p> | |
| 305 | )} | |
| 306 | {gitStatusError && ( | |
| 307 | <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p> | |
| 308 | )} | |
| 309 | </MenuPopup> | |
| 310 | </Menu> | |
| 311 | </Group> | |
| 434 | Cancel | |
| 435 | </Button> | |
| 436 | <Button size="sm" onClick={runDialogAction}> | |
| 437 | {activeDialogAction === "commit" | |
| 438 | ? "Commit" | |
| 439 | : activeDialogAction === "push" | |
| 440 | ? "Push" | |
| 441 | : "Create PR"} | |
| 442 | </Button> | |
| 443 | </DialogFooter> | |
| 444 | </DialogPopup> | |
| 445 | </Dialog> | |
| 446 | </> | |
| 312 | 447 | ); |
| 313 | 448 | } |
apps/web/src/wsNativeApi.ts+1| ... | ... | @@ -118,6 +118,7 @@ export function createWsNativeApi(): NativeApi { |
| 118 | 118 | }, |
| 119 | 119 | }, |
| 120 | 120 | git: { |
| 121 | pull: (input) => transport.request(WS_METHODS.gitPull, input), | |
| 121 | 122 | status: (input) => transport.request(WS_METHODS.gitStatus, input), |
| 122 | 123 | runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), |
| 123 | 124 | listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), |
packages/contracts/src/git.test.ts+17| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | gitPullInputSchema, | |
| 5 | gitPullResultSchema, | |
| 4 | 6 | gitRunStackedActionInputSchema, |
| 5 | 7 | gitRunStackedActionResultSchema, |
| 6 | 8 | gitStackedActionSchema, |
| ... | ... | @@ -14,6 +16,11 @@ describe("git contracts", () => { |
| 14 | 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 | 24 | it("parses git status result", () => { |
| 18 | 25 | const parsed = gitStatusResultSchema.parse({ |
| 19 | 26 | branch: "feature/git-actions", |
| ... | ... | @@ -79,4 +86,14 @@ describe("git contracts", () => { |
| 79 | 86 | expect(parsed.commit.status).toBe("created"); |
| 80 | 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 | 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 | 75 | export const gitStatusResultSchema = z.object({ |
| 72 | 76 | branch: z.string().min(1).nullable(), |
| 73 | 77 | hasWorkingTreeChanges: z.boolean(), |
| ... | ... | @@ -133,7 +137,16 @@ export const gitRunStackedActionResultSchema = z.object({ |
| 133 | 137 | }); |
| 134 | 138 | |
| 135 | 139 | export type GitStatusInput = z.input<typeof gitStatusInputSchema>; |
| 140 | export type GitPullInput = z.input<typeof gitPullInputSchema>; | |
| 136 | 141 | export type GitStatusResult = z.infer<typeof gitStatusResultSchema>; |
| 137 | 142 | export type GitStackedAction = z.infer<typeof gitStackedActionSchema>; |
| 138 | 143 | export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>; |
| 139 | 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 | 7 | GitInitInput, |
| 8 | 8 | GitListBranchesInput, |
| 9 | 9 | GitListBranchesResult, |
| 10 | GitPullInput, | |
| 11 | GitPullResult, | |
| 10 | 12 | GitRemoveWorktreeInput, |
| 11 | 13 | GitRunStackedActionInput, |
| 12 | 14 | GitRunStackedActionResult, |
| ... | ... | @@ -101,6 +103,7 @@ export interface NativeApi { |
| 101 | 103 | checkout: (input: GitCheckoutInput) => Promise<void>; |
| 102 | 104 | init: (input: GitInitInput) => Promise<void>; |
| 103 | 105 | // Stacked action API |
| 106 | pull: (input: GitPullInput) => Promise<GitPullResult>; | |
| 104 | 107 | status: (input: GitStatusInput) => Promise<GitStatusResult>; |
| 105 | 108 | runStackedAction: ( |
| 106 | 109 | input: GitRunStackedActionInput, |
packages/contracts/src/ws.ts+1| ... | ... | @@ -20,6 +20,7 @@ export const WS_METHODS = { |
| 20 | 20 | shellOpenInEditor: "shell.openInEditor", |
| 21 | 21 | |
| 22 | 22 | // Git methods |
| 23 | gitPull: "git.pull", | |
| 23 | 24 | gitStatus: "git.status", |
| 24 | 25 | gitRunStackedAction: "git.runStackedAction", |
| 25 | 26 | gitListBranches: "git.listBranches", |