From 5a30594009cc1170cd181649899c86b5452eff15 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Feb 2026 21:40:42 -0800 Subject: [PATCH] git actions --- apps/server/src/git.test.ts | 37 ++ apps/server/src/git.ts | 32 ++ apps/server/src/wsServer.test.ts | 5 + apps/server/src/wsServer.ts | 4 + .../GitActionsControl.logic.test.ts | 524 +++++++++++++++++ .../src/components/GitActionsControl.logic.ts | 197 +++++++ apps/web/src/components/GitActionsControl.tsx | 525 +++++++++++------- apps/web/src/wsNativeApi.ts | 1 + packages/contracts/src/git.test.ts | 17 + packages/contracts/src/git.ts | 13 + packages/contracts/src/ipc.ts | 3 + packages/contracts/src/ws.ts | 1 + 12 files changed, 1164 insertions(+), 195 deletions(-) create mode 100644 apps/web/src/components/GitActionsControl.logic.test.ts create mode 100644 apps/web/src/components/GitActionsControl.logic.ts diff --git a/apps/server/src/git.test.ts b/apps/server/src/git.test.ts index 1d9067b7aac3a9198fa29f4715a20a4727637bc2..2f091b2bb9ec959a0bc5a8d09d86d09e749a4121 100644 --- a/apps/server/src/git.test.ts +++ b/apps/server/src/git.test.ts @@ -11,6 +11,7 @@ import { createGitWorktree, initGitRepo, listGitBranches, + pullGitBranch, removeGitWorktree, runTerminalCommand, } from "./git"; @@ -604,6 +605,42 @@ describe("git integration", () => { expect(skipped.status).toBe("skipped_up_to_date"); }); + it("pulls behind branch and then reports up-to-date", async () => { + await using remote = await makeTmpDir(); + await using source = await makeTmpDir(); + await using clone = await makeTmpDir(); + await git(remote.path, "init --bare"); + + await initRepoWithCommit(source.path); + const initialBranch = (await listGitBranches({ cwd: source.path })).branches.find( + (branch) => branch.current, + )!.name; + await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); + await git(source.path, `push -u origin ${initialBranch}`); + + await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); + await git(clone.path, "config user.email 'test@test.com'"); + await git(clone.path, "config user.name 'Test'"); + await writeFile(path.join(clone.path, "CHANGELOG.md"), "remote change\n"); + await git(clone.path, "add CHANGELOG.md"); + await git(clone.path, "commit -m 'remote update'"); + await git(clone.path, `push origin ${initialBranch}`); + + const core = new GitCoreService(); + const pulled = await core.pullCurrentBranch(source.path); + expect(pulled.status).toBe("pulled"); + expect((await core.statusDetails(source.path)).behindCount).toBe(0); + + const skipped = await core.pullCurrentBranch(source.path); + expect(skipped.status).toBe("skipped_up_to_date"); + }); + + it("top-level pullGitBranch rejects when no upstream exists", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await expect(pullGitBranch({ cwd: tmp.path })).rejects.toThrow("no upstream"); + }); + it("lists branches when recency lookup fails", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts index b6fdbe63a84a86791e5cf0198506825868cf9cde..6ce097cfea334648a12910839aa67924f426965b 100644 --- a/apps/server/src/git.ts +++ b/apps/server/src/git.ts @@ -3,6 +3,8 @@ import os from "node:os"; import path from "node:path"; import { + gitPullInputSchema, + gitPullResultSchema, gitStatusInputSchema, gitStatusResultSchema, type GitCheckoutInput, @@ -12,6 +14,8 @@ import { type GitInitInput, type GitListBranchesInput, type GitListBranchesResult, + type GitPullInput, + type GitPullResult, type GitRemoveWorktreeInput, type GitStatusInput, type GitStatusResult, @@ -291,6 +295,29 @@ export class GitCoreService { }; } + async pullCurrentBranch(cwd: string): Promise { + const details = await this.statusDetails(cwd); + const branch = details.branch; + if (!branch) { + throw new Error("Cannot pull from detached HEAD."); + } + if (!details.hasUpstream) { + throw new Error("Current branch has no upstream configured. Push with upstream first."); + } + const beforeSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); + await executeGit(cwd, ["pull", "--ff-only"], { + timeoutMs: 30_000, + fallbackErrorMessage: "git pull failed", + }); + const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); + const refreshed = await this.statusDetails(cwd); + return gitPullResultSchema.parse({ + status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", + branch, + ...(refreshed.upstreamRef ? { upstreamBranch: refreshed.upstreamRef } : {}), + }); + } + async readRangeContext(cwd: string, baseBranch: string): Promise { const range = `${baseBranch}..HEAD`; const [commitSummary, diffSummary, diffPatch] = await Promise.all([ @@ -511,6 +538,11 @@ export async function listGitBranches(input: GitListBranchesInput): Promise { + const input = gitPullInputSchema.parse(raw); + return defaultGitCoreService.pullCurrentBranch(input.cwd); +} + export async function createGitWorktree( input: GitCreateWorktreeInput, ): Promise { diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 6cf6bdcd185b895aa25942ea667e815a35732d24..d86da3b184d0e21403590960075f1083aca33664 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -545,6 +545,11 @@ describe("WebSocket Server", () => { }); expect(afterInit.error).toBeUndefined(); expect((afterInit.result as { isRepo: boolean }).isRepo).toBe(true); + + const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: repoCwd }); + expect(pullResponse.result).toBeUndefined(); + expect(pullResponse.error?.message).toBeDefined(); + expect(pullResponse.error?.message).not.toContain("Unknown method"); }); it("responds to git.status via the git manager", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index f9458ca33dc5f9338b7286e9b77ebccb30cad7a8..6b12b13917b164fbc128fc6dcd1b7018b4484df4 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -27,6 +27,7 @@ import { createGitWorktree, initGitRepo, listGitBranches, + pullGitBranch, removeGitWorktree, } from "./git"; import { TerminalManager } from "./terminalManager"; @@ -363,6 +364,9 @@ export function createServer(options: ServerOptions) { case WS_METHODS.gitStatus: return gitManager.status(request.params as never); + case WS_METHODS.gitPull: + return pullGitBranch(request.params as never); + case WS_METHODS.gitRunStackedAction: return gitManager.runStackedAction(request.params as never); case WS_METHODS.gitListBranches: diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4cc8dd9e8b21b61c791041aaa7b67bf40b74557f --- /dev/null +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -0,0 +1,524 @@ +import type { GitStatusResult } from "@t3tools/contracts"; +import { assert, describe, it } from "vitest"; +import { + buildMenuItems, + requiresDefaultBranchConfirmation, + resolveQuickAction, +} from "./GitActionsControl.logic"; + +function status(overrides: Partial = {}): GitStatusResult { + return { + branch: "feature/test", + hasWorkingTreeChanges: false, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + openPr: null, + ...overrides, + }; +} + +describe("when: branch is clean and has an open PR", () => { + it("resolveQuickAction opens the existing PR", () => { + const quick = resolveQuickAction( + status({ + openPr: { + number: 10, + title: "Open PR", + url: "https://example.com/pr/10", + baseBranch: "main", + headBranch: "feature/test", + }, + }), + false, + ); + assert.deepInclude(quick, { kind: "open_pr", label: "Open PR", disabled: false }); + }); + + it("buildMenuItems disables commit/push and enables open PR", () => { + const items = buildMenuItems( + status({ + openPr: { + number: 11, + title: "Existing PR", + url: "https://example.com/pr/11", + baseBranch: "main", + headBranch: "feature/test", + }, + }), + false, + ); + assert.deepEqual(items, [ + { + id: "commit", + label: "Commit", + disabled: true, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }, + { + id: "push", + label: "Push", + disabled: true, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }, + { + id: "pr", + label: "Open PR", + disabled: false, + icon: "pr", + kind: "open_pr", + }, + ]); + }); +}); + +describe("when: actions are busy", () => { + it("resolveQuickAction returns running disabled state", () => { + const quick = resolveQuickAction(status(), true); + assert.deepInclude(quick, { + kind: "show_hint", + label: "Running...", + disabled: true, + hint: "Git action in progress.", + }); + }); + + it("buildMenuItems disables all actions", () => { + const items = buildMenuItems(status(), true); + assert.deepEqual(items, [ + { + id: "commit", + label: "Commit", + disabled: true, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }, + { + id: "push", + label: "Push", + disabled: true, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }, + { + id: "pr", + label: "Create PR", + disabled: true, + icon: "pr", + kind: "open_dialog", + dialogAction: "create_pr", + }, + ]); + }); +}); + +describe("when: git status is unavailable", () => { + it("resolveQuickAction returns unavailable disabled state", () => { + const quick = resolveQuickAction(null, false); + assert.deepInclude(quick, { + kind: "show_hint", + label: "Git actions", + disabled: true, + hint: "Git status is unavailable.", + }); + }); + + it("buildMenuItems returns no menu items", () => { + const items = buildMenuItems(null, false); + assert.deepEqual(items, []); + }); +}); + +describe("when: branch is clean, ahead, and has an open PR", () => { + it("resolveQuickAction prefers push", () => { + const quick = resolveQuickAction( + status({ + aheadCount: 3, + openPr: { + number: 13, + title: "Open PR", + url: "https://example.com/pr/13", + baseBranch: "main", + headBranch: "feature/test", + }, + }), + false, + ); + assert.deepInclude(quick, { kind: "run_action", action: "commit_push", label: "Push" }); + }); + + it("buildMenuItems enables push and keeps open PR available", () => { + const items = buildMenuItems( + status({ + aheadCount: 2, + openPr: { + number: 12, + title: "Existing PR", + url: "https://example.com/pr/12", + baseBranch: "main", + headBranch: "feature/test", + }, + }), + false, + ); + assert.deepEqual(items, [ + { + id: "commit", + label: "Commit", + disabled: true, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }, + { + id: "push", + label: "Push", + disabled: false, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }, + { + id: "pr", + label: "Open PR", + disabled: false, + icon: "pr", + kind: "open_pr", + }, + ]); + }); +}); + +describe("when: branch is clean, ahead, and has no open PR", () => { + it("resolveQuickAction pushes and creates a PR", () => { + const quick = resolveQuickAction(status({ aheadCount: 2, openPr: null }), false); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push_pr", + label: "Push & create PR", + }); + }); + + it("buildMenuItems enables push and create PR, with commit disabled", () => { + const items = buildMenuItems(status({ aheadCount: 2, openPr: null }), false); + assert.deepEqual(items, [ + { + id: "commit", + label: "Commit", + disabled: true, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }, + { + id: "push", + label: "Push", + disabled: false, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }, + { + id: "pr", + label: "Create PR", + disabled: false, + icon: "pr", + kind: "open_dialog", + dialogAction: "create_pr", + }, + ]); + }); +}); + +describe("when: branch is clean, up to date, and has no open PR", () => { + it("resolveQuickAction returns disabled no-action state", () => { + const quick = resolveQuickAction( + status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, openPr: null }), + false, + ); + assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); + }); + + it("buildMenuItems disables commit, push, and create PR", () => { + const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, openPr: null }), false); + assert.deepEqual(items, [ + { + id: "commit", + label: "Commit", + disabled: true, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }, + { + id: "push", + label: "Push", + disabled: true, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }, + { + id: "pr", + label: "Create PR", + disabled: true, + icon: "pr", + kind: "open_dialog", + dialogAction: "create_pr", + }, + ]); + }); +}); + +describe("when: branch is behind upstream", () => { + it("resolveQuickAction returns pull", () => { + const quick = resolveQuickAction(status({ behindCount: 2 }), false); + assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false }); + }); + + it("buildMenuItems disables push and create PR", () => { + const items = buildMenuItems(status({ behindCount: 1, openPr: null }), false); + assert.deepEqual(items, [ + { + id: "commit", + label: "Commit", + disabled: true, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }, + { + id: "push", + label: "Push", + disabled: true, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }, + { + id: "pr", + label: "Create PR", + disabled: true, + icon: "pr", + kind: "open_dialog", + dialogAction: "create_pr", + }, + ]); + }); +}); + +describe("when: working tree has local changes", () => { + it("resolveQuickAction returns commit", () => { + const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false); + assert.deepInclude(quick, { kind: "run_action", action: "commit", label: "Commit" }); + }); + + it("buildMenuItems enables commit and disables push and PR", () => { + const items = buildMenuItems(status({ hasWorkingTreeChanges: true }), false); + assert.deepEqual(items, [ + { + id: "commit", + label: "Commit", + disabled: false, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }, + { + id: "push", + label: "Push", + disabled: true, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }, + { + id: "pr", + label: "Create PR", + disabled: true, + icon: "pr", + kind: "open_dialog", + dialogAction: "create_pr", + }, + ]); + }); +}); + +describe("when: working tree has local changes and branch is behind upstream", () => { + it("resolveQuickAction still prefers commit", () => { + const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true, behindCount: 1 }), false); + assert.deepInclude(quick, { kind: "run_action", action: "commit", label: "Commit" }); + }); + + it("buildMenuItems enables commit and keeps push and PR disabled", () => { + const items = buildMenuItems(status({ hasWorkingTreeChanges: true, behindCount: 2 }), false); + assert.deepEqual(items, [ + { + id: "commit", + label: "Commit", + disabled: false, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }, + { + id: "push", + label: "Push", + disabled: true, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }, + { + id: "pr", + label: "Create PR", + disabled: true, + icon: "pr", + kind: "open_dialog", + dialogAction: "create_pr", + }, + ]); + }); +}); + +describe("when: HEAD is detached and there are no local changes", () => { + it("resolveQuickAction shows detached head hint", () => { + const quick = resolveQuickAction( + status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }), + false, + ); + assert.deepInclude(quick, { kind: "show_hint", label: "Detached HEAD", disabled: false }); + }); + + it("buildMenuItems keeps commit, push, and PR disabled", () => { + const items = buildMenuItems(status({ branch: null, hasWorkingTreeChanges: false }), false); + assert.deepEqual(items, [ + { + id: "commit", + label: "Commit", + disabled: true, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }, + { + id: "push", + label: "Push", + disabled: true, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }, + { + id: "pr", + label: "Create PR", + disabled: true, + icon: "pr", + kind: "open_dialog", + dialogAction: "create_pr", + }, + ]); + }); +}); + +describe("when: branch has no upstream configured", () => { + it("resolveQuickAction runs push and create PR when clean and no open PR", () => { + const quick = resolveQuickAction(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push_pr", + label: "Push & create PR", + disabled: false, + }); + }); + + it("resolveQuickAction opens PR when clean, no upstream, and no local commits ahead", () => { + const quick = resolveQuickAction( + status({ + hasUpstream: false, + aheadCount: 0, + openPr: { + number: 14, + title: "Existing PR", + url: "https://example.com/pr/14", + baseBranch: "main", + headBranch: "feature/test", + }, + }), + false, + ); + assert.deepInclude(quick, { + kind: "open_pr", + label: "Open PR", + disabled: false, + }); + }); + + it("resolveQuickAction runs push when clean, no upstream, and local commits are ahead", () => { + const quick = resolveQuickAction( + status({ + hasUpstream: false, + aheadCount: 1, + openPr: { + number: 15, + title: "Existing PR", + url: "https://example.com/pr/15", + baseBranch: "main", + headBranch: "feature/test", + }, + }), + false, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push", + label: "Push", + disabled: false, + }); + }); + + it("buildMenuItems enables push and disables create PR", () => { + const items = buildMenuItems(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false); + assert.deepEqual(items, [ + { + id: "commit", + label: "Commit", + disabled: true, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }, + { + id: "push", + label: "Push", + disabled: false, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }, + { + id: "pr", + label: "Create PR", + disabled: true, + icon: "pr", + kind: "open_dialog", + dialogAction: "create_pr", + }, + ]); + }); +}); + +describe("requiresDefaultBranchConfirmation", () => { + it("requires confirmation only for push actions on default branch", () => { + assert.isFalse(requiresDefaultBranchConfirmation("commit", true)); + assert.isTrue(requiresDefaultBranchConfirmation("commit_push", true)); + assert.isTrue(requiresDefaultBranchConfirmation("commit_push_pr", true)); + assert.isFalse(requiresDefaultBranchConfirmation("commit_push", false)); + }); +}); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts new file mode 100644 index 0000000000000000000000000000000000000000..fda15debc93b523b691d2581d9f347033cf2bd41 --- /dev/null +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -0,0 +1,197 @@ +import type { GitRunStackedActionResult, GitStackedAction, GitStatusResult } from "@t3tools/contracts"; + +export type GitActionIconName = "commit" | "push" | "pr"; + +export type GitDialogAction = "commit" | "push" | "create_pr"; + +export interface GitActionMenuItem { + id: "commit" | "push" | "pr"; + label: string; + disabled: boolean; + icon: GitActionIconName; + kind: "open_dialog" | "open_pr"; + dialogAction?: GitDialogAction; +} + +export interface GitQuickAction { + label: string; + disabled: boolean; + kind: "run_action" | "run_pull" | "open_pr" | "show_hint"; + action?: GitStackedAction; + hint?: string; +} + +export function describeGitResult(result: GitRunStackedActionResult): string { + const parts: string[] = []; + + if (result.commit.status === "created") { + parts.push(result.commit.subject ?? "Committed changes"); + } + + if (result.push.status === "pushed") { + parts.push( + result.push.upstreamBranch + ? `Pushed to ${result.push.upstreamBranch}` + : "Pushed", + ); + } + + if (result.pr.status === "created") { + parts.push(result.pr.number ? `Created PR #${result.pr.number}` : "Created PR"); + } else if (result.pr.status === "opened_existing") { + parts.push(result.pr.number ? `PR #${result.pr.number}` : "Opened existing PR"); + } + + return parts.join(" · ") || "No changes needed."; +} + +export function buildMenuItems( + gitStatus: GitStatusResult | null, + isBusy: boolean, +): GitActionMenuItem[] { + if (!gitStatus) return []; + + const hasBranch = gitStatus.branch !== null; + const hasChanges = gitStatus.hasWorkingTreeChanges; + const hasOpenPr = gitStatus.openPr !== null; + const isBehind = gitStatus.behindCount > 0; + const canCommit = !isBusy && hasChanges; + const canPush = + !isBusy && + hasBranch && + !hasChanges && + !isBehind && + (gitStatus.aheadCount > 0 || !gitStatus.hasUpstream); + const canCreatePr = + !isBusy && + hasBranch && + !hasChanges && + !hasOpenPr && + gitStatus.aheadCount > 0 && + gitStatus.hasUpstream && + !isBehind; + const canOpenPr = !isBusy && hasOpenPr; + + return [ + { + id: "commit", + label: "Commit", + disabled: !canCommit, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }, + { + id: "push", + label: "Push", + disabled: !canPush, + icon: "push", + kind: "open_dialog", + dialogAction: "push", + }, + hasOpenPr + ? { + id: "pr", + label: "Open PR", + disabled: !canOpenPr, + icon: "pr", + kind: "open_pr", + } + : { + id: "pr", + label: "Create PR", + disabled: !canCreatePr, + icon: "pr", + kind: "open_dialog", + dialogAction: "create_pr", + }, + ]; +} + +export function resolveQuickAction( + gitStatus: GitStatusResult | null, + isBusy: boolean, +): GitQuickAction { + if (isBusy) { + return { label: "Running...", disabled: true, kind: "show_hint", hint: "Git action in progress." }; + } + + if (!gitStatus) { + return { + label: "Git actions", + disabled: true, + kind: "show_hint", + hint: "Git status is unavailable.", + }; + } + + const hasBranch = gitStatus.branch !== null; + const hasChanges = gitStatus.hasWorkingTreeChanges; + const hasOpenPr = gitStatus.openPr !== null; + const isBehind = gitStatus.behindCount > 0; + const canPush = hasBranch && !hasChanges && !isBehind; + + if (!hasChanges && hasOpenPr && gitStatus.aheadCount === 0 && !isBehind) { + return { label: "Open PR", disabled: false, kind: "open_pr" }; + } + + if (!hasChanges && isBehind) { + return { + label: "Pull", + disabled: false, + kind: "run_pull", + }; + } + + if (!hasChanges && canPush && (gitStatus.aheadCount > 0 || !gitStatus.hasUpstream)) { + if (hasOpenPr) { + return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; + } + return { + label: "Push & create PR", + disabled: false, + kind: "run_action", + action: "commit_push_pr", + }; + } + + if (hasChanges) { + return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; + } + + if (hasOpenPr) { + return { label: "Open PR", disabled: false, kind: "open_pr" }; + } + + if (hasBranch && gitStatus.hasUpstream && !isBehind) { + if (gitStatus.aheadCount > 0) { + return { label: "Create PR", disabled: false, kind: "run_action", action: "commit_push_pr" }; + } + return { + label: "Commit", + disabled: true, + kind: "show_hint", + hint: "Branch is up to date. Nothing to commit, push, pull, or open as a PR.", + }; + } + + if (!hasBranch) { + return { + label: "Detached HEAD", + disabled: false, + kind: "show_hint", + hint: "Create and checkout a branch before pushing or opening a PR.", + }; + } + + return { label: "Commit", disabled: true, kind: "show_hint", hint: "No git action needed." }; +} + +export function requiresDefaultBranchConfirmation( + action: GitStackedAction, + isDefaultBranch: boolean, +): boolean { + if (!isDefaultBranch) return false; + return action === "commit_push" || action === "commit_push_pr"; +} + diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index da27585e481f2fe48b1b68221f18b99738931b4f..f835e616bbe4857f733a49d439722efe34898e28 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,16 +1,34 @@ import { - type GitRunStackedActionResult, type GitStackedAction, - type GitStatusResult, type NativeApi, } from "@t3tools/contracts"; import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; import { GitHubIcon } from "./Icons"; +import { + buildMenuItems, + type GitActionIconName, + type GitActionMenuItem, + type GitDialogAction, + type GitQuickAction, + describeGitResult, + requiresDefaultBranchConfirmation, + resolveQuickAction, +} from "./GitActionsControl.logic"; import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; import { Group, GroupSeparator } from "~/components/ui/group"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { Textarea } from "~/components/ui/textarea"; import { toastManager } from "~/components/ui/toast"; interface GitActionsControlProps { @@ -18,102 +36,22 @@ interface GitActionsControlProps { gitCwd: string | null; } -type GitActionIconName = "commit" | "push" | "pr"; - -interface GitActionMenuItem { - id: "commit" | "push" | "pr"; - label: string; - disabled: boolean; - icon: GitActionIconName; - kind: "run_action" | "open_pr"; - action?: GitStackedAction; -} - function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { if (icon === "commit") return ; if (icon === "push") return ; return ; } -function describeGitResult(result: GitRunStackedActionResult): string { - const parts: string[] = []; - - if (result.commit.status === "created") { - parts.push(result.commit.subject ?? "Committed changes"); +function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { + if (quickAction.kind === "open_pr") return ; + if (quickAction.kind === "run_pull") return ; + if (quickAction.kind === "run_action") { + if (quickAction.action === "commit") return ; + if (quickAction.action === "commit_push") return ; + return ; } - - if (result.push.status === "pushed") { - parts.push( - result.push.upstreamBranch - ? `Pushed to ${result.push.upstreamBranch}` - : "Pushed", - ); - } - - if (result.pr.status === "created") { - parts.push(result.pr.number ? `Created PR #${result.pr.number}` : "Created PR"); - } else if (result.pr.status === "opened_existing") { - parts.push(result.pr.number ? `PR #${result.pr.number}` : "Opened existing PR"); - } - - return parts.join(" · ") || "No changes needed."; -} - -function buildGitActionMenuItems( - gitStatus: GitStatusResult | null, - isDisabled: boolean, -): GitActionMenuItem[] { - if (!gitStatus) return []; - - const hasBranch = gitStatus.branch !== null; - const hasOpenPr = gitStatus.openPr !== null; - const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasAhead = gitStatus.aheadCount > 0; - const canCommit = !isDisabled && hasChanges; - const canPush = !isDisabled && hasBranch && !hasChanges && hasAhead; - const canOpenPr = !isDisabled && hasOpenPr; - const canCreatePr = - !isDisabled && - hasBranch && - !hasOpenPr && - !hasChanges && - gitStatus.hasUpstream && - gitStatus.behindCount === 0; - - return [ - { - id: "commit", - label: "Commit", - disabled: !canCommit, - icon: "commit", - kind: "run_action", - action: "commit", - }, - { - id: "push", - label: "Push", - disabled: !canPush, - icon: "push", - kind: "run_action", - action: "commit_push", - }, - hasOpenPr - ? { - id: "pr", - label: "View PR", - disabled: !canOpenPr, - icon: "pr", - kind: "open_pr", - } - : { - id: "pr", - label: "Create PR", - disabled: !canCreatePr, - icon: "pr", - kind: "run_action", - action: "commit_push_pr", - }, - ]; + if (quickAction.label === "Commit") return ; + return ; } function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] { @@ -135,34 +73,64 @@ function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { const queryClient = useQueryClient(); - const [selectedGitActionId, setSelectedGitActionId] = useState("commit"); + const [activeDialogAction, setActiveDialogAction] = useState(null); + const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const { data: gitStatus = null, error: gitStatusError } = useQuery( gitStatusQueryOptions(api, gitCwd), ); + const { data: branchList = null } = useQuery({ + queryKey: ["git", "branches", gitCwd], + queryFn: async () => { + if (!api || !gitCwd) throw new Error("Git branches are unavailable."); + return api.git.listBranches({ cwd: gitCwd }); + }, + enabled: !!api && !!gitCwd, + }); + const runImmediateGitActionMutation = useMutation({ - mutationFn: async (action: GitStackedAction) => { + mutationFn: async ({ + action, + commitMessage, + }: { + action: GitStackedAction; + commitMessage?: string; + }) => { if (!api || !gitCwd) { throw new Error("Git action is unavailable."); } - return api.git.runStackedAction({ cwd: gitCwd, action }); + return api.git.runStackedAction({ cwd: gitCwd, action, ...(commitMessage ? { commitMessage } : {}) }); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["git"] }); + }, + }); + const pullMutation = useMutation({ + mutationFn: async () => { + if (!api || !gitCwd) throw new Error("Git pull is unavailable."); + return api.git.pull({ cwd: gitCwd }); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["git"] }); }, }); - const isGitActionRunning = runImmediateGitActionMutation.isPending; + const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending; + const isDefaultBranch = useMemo(() => { + const currentBranch = gitStatus?.branch; + if (!currentBranch) return false; + const current = branchList?.branches.find((branch) => branch.name === currentBranch); + return current?.isDefault ?? (currentBranch === "main" || currentBranch === "master"); + }, [branchList?.branches, gitStatus?.branch]); - const gitBaseDisabled = !api || !gitCwd || !gitStatus || isGitActionRunning; const gitActionMenuItems = useMemo( - () => buildGitActionMenuItems(gitStatus, gitBaseDisabled), - [gitBaseDisabled, gitStatus], + () => buildMenuItems(gitStatus, isGitActionRunning), + [gitStatus, isGitActionRunning], ); - const selectedGitActionItem = useMemo( - () => gitActionMenuItems.find((item) => item.id === selectedGitActionId) ?? null, - [gitActionMenuItems, selectedGitActionId], + const quickAction = useMemo( + () => resolveQuickAction(gitStatus, isGitActionRunning), + [gitStatus, isGitActionRunning], ); const refreshGitStatus = useCallback(async () => { @@ -170,15 +138,71 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp await queryClient.invalidateQueries({ queryKey: ["git"] }); }, [api, gitCwd, queryClient]); - const runGitActionImmediately = useCallback( - (action: GitStackedAction) => { - const promise = runImmediateGitActionMutation.mutateAsync(action); + const maybeConfirmPushToDefaultBranch = useCallback( + async (action: GitStackedAction): Promise => { + if (!api) return false; + if (!requiresDefaultBranchConfirmation(action, isDefaultBranch) || !gitStatus?.branch) { + return true; + } + return api.dialogs.confirm( + `Push to default branch "${gitStatus.branch}"? This will update the shared base branch.`, + ); + }, + [api, gitStatus?.branch, isDefaultBranch], + ); + + const openExistingPr = useCallback(async () => { + if (!api) { + toastManager.add({ type: "error", title: "Link opening is unavailable." }); + return; + } + const prUrl = gitStatus?.openPr?.url ?? null; + if (!prUrl) { + toastManager.add({ type: "error", title: "No open PR found." }); + return; + } + const promise = api.shell.openExternal(prUrl); + toastManager.promise(promise, { + loading: { title: "Opening PR..." }, + success: { title: "PR opened" }, + error: (err) => ({ + title: "Unable to open PR link", + description: err instanceof Error ? err.message : "An error occurred.", + }), + }); + void promise.catch(() => undefined); + }, [api, gitStatus?.openPr?.url]); + + const runGitActionWithToast = useCallback( + async ({ + action, + commitMessage, + }: { + action: GitStackedAction; + commitMessage?: string; + }) => { + const confirmed = await maybeConfirmPushToDefaultBranch(action); + if (!confirmed) return; + + const promise = runImmediateGitActionMutation.mutateAsync({ + action, + ...(commitMessage ? { commitMessage } : {}), + }); toastManager.promise(promise, { loading: { - title: action === "commit_push" ? "Pushing..." : "Creating PR...", + title: + action === "commit" + ? "Committing..." + : action === "commit_push" + ? "Pushing..." + : "Creating PR...", }, success: (result) => { - const prUrl = result.pr.url; + const prUrl = result.pr.url ?? gitStatus?.openPr?.url; + const shouldOfferCreatePrCta = + action === "commit_push" && + !prUrl && + result.push.status === "pushed"; return { title: "Done", description: describeGitResult(result), @@ -190,7 +214,15 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp onClick: () => void api?.shell.openExternal(prUrl), }, } - : {}), + : shouldOfferCreatePrCta + ? { + timeout: 10_000, + actionProps: { + children: "Create PR", + onClick: () => setActiveDialogAction("create_pr"), + }, + } + : {}), }; }, error: (err) => ({ @@ -200,114 +232,217 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp }); void promise.catch(() => undefined); }, - [api, runImmediateGitActionMutation], + [ + api, + gitStatus?.openPr?.url, + maybeConfirmPushToDefaultBranch, + runImmediateGitActionMutation, + setActiveDialogAction, + ], ); - const openExistingPr = useCallback(() => { - if (!api) { - toastManager.add({ type: "error", title: "Link opening is unavailable." }); + const runQuickAction = useCallback(() => { + if (quickAction.kind === "open_pr") { + void openExistingPr(); return; } - - const prUrl = gitStatus?.openPr?.url ?? null; - if (!prUrl) { - toastManager.add({ type: "error", title: "No open PR found." }); + if (quickAction.kind === "run_pull") { + const promise = pullMutation.mutateAsync(); + toastManager.promise(promise, { + loading: { title: "Pulling..." }, + success: (result) => ({ + title: result.status === "pulled" ? "Pulled" : "Already up to date", + description: + result.status === "pulled" + ? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}` + : `${result.branch} is already synchronized.`, + }), + error: (err) => ({ + title: "Pull failed", + description: err instanceof Error ? err.message : "An error occurred.", + }), + }); + void promise.catch(() => undefined); return; } - - void api.shell.openExternal(prUrl).catch((error) => { + if (quickAction.kind === "show_hint") { toastManager.add({ - type: "error", - title: "Unable to open PR link", - description: error instanceof Error ? error.message : undefined, + type: "info", + title: quickAction.label, + description: quickAction.hint, }); - }); - }, [api, gitStatus?.openPr?.url]); - - const runSelectedAction = useCallback(() => { - if (!selectedGitActionItem || selectedGitActionItem.disabled) return; - if (selectedGitActionItem.kind === "open_pr") { - openExistingPr(); return; } - if (selectedGitActionItem.action) { - runGitActionImmediately(selectedGitActionItem.action); + if (quickAction.action) { + void runGitActionWithToast({ action: quickAction.action }); } - }, [openExistingPr, runGitActionImmediately, selectedGitActionItem]); + }, [openExistingPr, pullMutation, quickAction, runGitActionWithToast]); - useEffect(() => { - setSelectedGitActionId("commit"); - }, [gitCwd]); + const openDialogForMenuItem = useCallback( + (item: GitActionMenuItem) => { + if (item.disabled) return; + if (item.kind === "open_pr") { + void openExistingPr(); + return; + } + if (item.dialogAction) { + setActiveDialogAction(item.dialogAction); + } + }, + [openExistingPr], + ); - useEffect(() => { - if (selectedGitActionItem) return; - const fallback = gitActionMenuItems.find((item) => !item.disabled) ?? gitActionMenuItems[0]; - if (fallback) { - setSelectedGitActionId(fallback.id); - } - }, [gitActionMenuItems, selectedGitActionItem]); + const runDialogAction = useCallback(() => { + if (!activeDialogAction) return; + const action: GitStackedAction = + activeDialogAction === "commit" + ? "commit" + : activeDialogAction === "push" + ? "commit_push" + : "commit_push_pr"; + const commitMessage = + activeDialogAction === "commit" ? dialogCommitMessage.trim() : ""; + setActiveDialogAction(null); + void runGitActionWithToast({ + action, + ...(commitMessage ? { commitMessage } : {}), + }); + }, [activeDialogAction, dialogCommitMessage, runGitActionWithToast]); + + const dialogTitle = + activeDialogAction === "commit" + ? "Commit changes" + : activeDialogAction === "push" + ? "Push branch" + : "Create pull request"; + const dialogDescription = + activeDialogAction === "commit" + ? "Review and confirm your commit. Leave the message blank to auto-generate one." + : activeDialogAction === "push" + ? "Push this branch now. If this is the default branch, you'll be asked to confirm." + : "Create a pull request using generated title/body content."; if (!gitCwd) return null; return ( - - - - + + + + { + if (open) void refreshGitStatus(); + }} + > + } + disabled={isGitActionRunning} + > + + + {gitActionMenuItems.map((item) => ( + { + openDialogForMenuItem(item); + }} + > + + {item.label} + + ))} + {gitStatus?.branch === null && ( +

+ Detached HEAD: create and checkout a branch to enable push and PR actions. +

+ )} + {gitStatus && + gitStatus.branch !== null && + !gitStatus.hasWorkingTreeChanges && + gitStatus.behindCount > 0 && ( +

+ Behind upstream. Pull/rebase first. +

+ )} + {gitStatusError && ( +

{gitStatusError.message}

+ )} +
+
+
+ + { - if (open) void refreshGitStatus(); + if (!open) { + setActiveDialogAction(null); + setDialogCommitMessage(""); + } }} > - } - disabled={!gitStatus || isGitActionRunning} - > - - - {gitActionMenuItems.map((item) => ( - + + {dialogTitle} + {dialogDescription} + + +
+

+ Branch:{" "} + {gitStatus?.branch ?? "(detached HEAD)"} +

+

+ Working tree:{" "} + {gitStatus?.hasWorkingTreeChanges ? "Has changes" : "Clean"} +

+

+ Ahead/behind:{" "} + {gitStatus ? `${gitStatus.aheadCount}/${gitStatus.behindCount}` : "unknown"} +

+
+ {activeDialogAction === "commit" && ( +
+

Commit message (optional)

+