diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 05b2ade03eb..be752b3c2ef 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -260,6 +260,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); const result = yield* listGitBranches({ cwd: tmp }); expect(result.isRepo).toBe(true); + expect(result.hasOriginRemote).toBe(false); expect(result.branches.length).toBeGreaterThanOrEqual(1); }), ); @@ -273,6 +274,7 @@ it.layer(TestLayer)("git integration", (it) => { const tmp = yield* makeTmpDir(); const result = yield* listGitBranches({ cwd: tmp }); expect(result.isRepo).toBe(false); + expect(result.hasOriginRemote).toBe(false); expect(result.branches).toEqual([]); }), ); @@ -431,6 +433,7 @@ it.layer(TestLayer)("git integration", (it) => { const result = yield* listGitBranches({ cwd: tmp }); const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote); + expect(result.hasOriginRemote).toBe(true); expect(firstRemoteIndex).toBeGreaterThan(0); expect(result.branches.slice(0, firstRemoteIndex).every((branch) => !branch.isRemote)).toBe( true, diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 05f458af63d..be853eb5fa8 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -931,7 +931,7 @@ const makeGitCore = Effect.gen(function* () { if (localBranchResult.code !== 0) { const stderr = localBranchResult.stderr.trim(); if (stderr.toLowerCase().includes("not a git repository")) { - return { branches: [], isRepo: false }; + return { branches: [], isRepo: false, hasOriginRemote: false }; } return yield* createGitCommandError( "GitCore.listBranches", @@ -1097,7 +1097,7 @@ const makeGitCore = Effect.gen(function* () { const branches = [...localBranches, ...remoteBranches]; - return { branches, isRepo: true }; + return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") }; }); const createWorktree: GitCoreShape["createWorktree"] = (input) => diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index ecb4c09ef6a..f1ad0095f14 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1577,6 +1577,7 @@ describe("WebSocket Server", () => { Effect.succeed({ branches: [], isRepo: false, + hasOriginRemote: false, }), ); const initRepo = vi.fn(() => Effect.void); @@ -1608,7 +1609,7 @@ describe("WebSocket Server", () => { const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" }); expect(listResponse.error).toBeUndefined(); - expect(listResponse.result).toEqual({ branches: [], isRepo: false }); + expect(listResponse.result).toEqual({ branches: [], isRepo: false, hasOriginRemote: false }); expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" }); const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" }); diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js index daa58d0f120..82804cec786 100644 --- a/apps/web/public/mockServiceWorker.js +++ b/apps/web/public/mockServiceWorker.js @@ -7,114 +7,111 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.12.10' -const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' -const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') -const activeClientIds = new Set() +const PACKAGE_VERSION = "2.12.10"; +const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82"; +const IS_MOCKED_RESPONSE = Symbol("isMockedResponse"); +const activeClientIds = new Set(); -addEventListener('install', function () { - self.skipWaiting() -}) +addEventListener("install", function () { + self.skipWaiting(); +}); -addEventListener('activate', function (event) { - event.waitUntil(self.clients.claim()) -}) +addEventListener("activate", function (event) { + event.waitUntil(self.clients.claim()); +}); -addEventListener('message', async function (event) { - const clientId = Reflect.get(event.source || {}, 'id') +addEventListener("message", async function (event) { + const clientId = Reflect.get(event.source || {}, "id"); if (!clientId || !self.clients) { - return + return; } - const client = await self.clients.get(clientId) + const client = await self.clients.get(clientId); if (!client) { - return + return; } const allClients = await self.clients.matchAll({ - type: 'window', - }) + type: "window", + }); switch (event.data) { - case 'KEEPALIVE_REQUEST': { + case "KEEPALIVE_REQUEST": { sendToClient(client, { - type: 'KEEPALIVE_RESPONSE', - }) - break + type: "KEEPALIVE_RESPONSE", + }); + break; } - case 'INTEGRITY_CHECK_REQUEST': { + case "INTEGRITY_CHECK_REQUEST": { sendToClient(client, { - type: 'INTEGRITY_CHECK_RESPONSE', + type: "INTEGRITY_CHECK_RESPONSE", payload: { packageVersion: PACKAGE_VERSION, checksum: INTEGRITY_CHECKSUM, }, - }) - break + }); + break; } - case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId) + case "MOCK_ACTIVATE": { + activeClientIds.add(clientId); sendToClient(client, { - type: 'MOCKING_ENABLED', + type: "MOCKING_ENABLED", payload: { client: { id: client.id, frameType: client.frameType, }, }, - }) - break + }); + break; } - case 'CLIENT_CLOSED': { - activeClientIds.delete(clientId) + case "CLIENT_CLOSED": { + activeClientIds.delete(clientId); const remainingClients = allClients.filter((client) => { - return client.id !== clientId - }) + return client.id !== clientId; + }); // Unregister itself when there are no more clients if (remainingClients.length === 0) { - self.registration.unregister() + self.registration.unregister(); } - break + break; } } -}) +}); -addEventListener('fetch', function (event) { - const requestInterceptedAt = Date.now() +addEventListener("fetch", function (event) { + const requestInterceptedAt = Date.now(); // Bypass navigation requests. - if (event.request.mode === 'navigate') { - return + if (event.request.mode === "navigate") { + return; } // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. - if ( - event.request.cache === 'only-if-cached' && - event.request.mode !== 'same-origin' - ) { - return + if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") { + return; } // Bypass all requests when there are no active clients. // Prevents the self-unregistered worked from handling requests // after it's been terminated (still remains active until the next reload). if (activeClientIds.size === 0) { - return + return; } - const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) -}) + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); +}); /** * @param {FetchEvent} event @@ -122,28 +119,23 @@ addEventListener('fetch', function (event) { * @param {number} requestInterceptedAt */ async function handleRequest(event, requestId, requestInterceptedAt) { - const client = await resolveMainClient(event) - const requestCloneForEvents = event.request.clone() - const response = await getResponse( - event, - client, - requestId, - requestInterceptedAt, - ) + const client = await resolveMainClient(event); + const requestCloneForEvents = event.request.clone(); + const response = await getResponse(event, client, requestId, requestInterceptedAt); // Send back the response clone for the "response:*" life-cycle events. // Ensure MSW is active and ready to handle the message, otherwise // this message will pend indefinitely. if (client && activeClientIds.has(client.id)) { - const serializedRequest = await serializeRequest(requestCloneForEvents) + const serializedRequest = await serializeRequest(requestCloneForEvents); // Clone the response so both the client and the library could consume it. - const responseClone = response.clone() + const responseClone = response.clone(); sendToClient( client, { - type: 'RESPONSE', + type: "RESPONSE", payload: { isMockedResponse: IS_MOCKED_RESPONSE in response, request: { @@ -160,10 +152,10 @@ async function handleRequest(event, requestId, requestInterceptedAt) { }, }, responseClone.body ? [serializedRequest.body, responseClone.body] : [], - ) + ); } - return response + return response; } /** @@ -175,30 +167,30 @@ async function handleRequest(event, requestId, requestInterceptedAt) { * @returns {Promise} */ async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId) + const client = await self.clients.get(event.clientId); if (activeClientIds.has(event.clientId)) { - return client + return client; } - if (client?.frameType === 'top-level') { - return client + if (client?.frameType === "top-level") { + return client; } const allClients = await self.clients.matchAll({ - type: 'window', - }) + type: "window", + }); return allClients .filter((client) => { // Get only those clients that are currently visible. - return client.visibilityState === 'visible' + return client.visibilityState === "visible"; }) .find((client) => { // Find the client ID that's recorded in the // set of clients that have registered the worker. - return activeClientIds.has(client.id) - }) + return activeClientIds.has(client.id); + }); } /** @@ -211,36 +203,34 @@ async function resolveMainClient(event) { async function getResponse(event, client, requestId, requestInterceptedAt) { // Clone the request because it might've been already used // (i.e. its body has been read and sent to the client). - const requestClone = event.request.clone() + const requestClone = event.request.clone(); function passthrough() { // Cast the request headers to a new Headers instance // so the headers can be manipulated with. - const headers = new Headers(requestClone.headers) + const headers = new Headers(requestClone.headers); // Remove the "accept" header value that marked this request as passthrough. // This prevents request alteration and also keeps it compliant with the // user-defined CORS policies. - const acceptHeader = headers.get('accept') + const acceptHeader = headers.get("accept"); if (acceptHeader) { - const values = acceptHeader.split(',').map((value) => value.trim()) - const filteredValues = values.filter( - (value) => value !== 'msw/passthrough', - ) + const values = acceptHeader.split(",").map((value) => value.trim()); + const filteredValues = values.filter((value) => value !== "msw/passthrough"); if (filteredValues.length > 0) { - headers.set('accept', filteredValues.join(', ')) + headers.set("accept", filteredValues.join(", ")); } else { - headers.delete('accept') + headers.delete("accept"); } } - return fetch(requestClone, { headers }) + return fetch(requestClone, { headers }); } // Bypass mocking when the client is not active. if (!client) { - return passthrough() + return passthrough(); } // Bypass initial page load requests (i.e. static assets). @@ -248,15 +238,15 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet // and is not ready to handle requests. if (!activeClientIds.has(client.id)) { - return passthrough() + return passthrough(); } // Notify the client that a request has been intercepted. - const serializedRequest = await serializeRequest(event.request) + const serializedRequest = await serializeRequest(event.request); const clientMessage = await sendToClient( client, { - type: 'REQUEST', + type: "REQUEST", payload: { id: requestId, interceptedAt: requestInterceptedAt, @@ -264,19 +254,19 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { }, }, [serializedRequest.body], - ) + ); switch (clientMessage.type) { - case 'MOCK_RESPONSE': { - return respondWithMock(clientMessage.data) + case "MOCK_RESPONSE": { + return respondWithMock(clientMessage.data); } - case 'PASSTHROUGH': { - return passthrough() + case "PASSTHROUGH": { + return passthrough(); } } - return passthrough() + return passthrough(); } /** @@ -287,21 +277,18 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { */ function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { - const channel = new MessageChannel() + const channel = new MessageChannel(); channel.port1.onmessage = (event) => { if (event.data && event.data.error) { - return reject(event.data.error) + return reject(event.data.error); } - resolve(event.data) - } + resolve(event.data); + }; - client.postMessage(message, [ - channel.port2, - ...transferrables.filter(Boolean), - ]) - }) + client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]); + }); } /** @@ -314,17 +301,17 @@ function respondWithMock(response) { // instance will have status code set to 0. Since it's not possible to create // a Response instance with status code 0, handle that use-case separately. if (response.status === 0) { - return Response.error() + return Response.error(); } - const mockedResponse = new Response(response.body, response) + const mockedResponse = new Response(response.body, response); Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { value: true, enumerable: true, - }) + }); - return mockedResponse + return mockedResponse; } /** @@ -345,5 +332,5 @@ async function serializeRequest(request) { referrerPolicy: request.referrerPolicy, body: await request.arrayBuffer(), keepalive: request.keepalive, - } + }; } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 8e02af232c4..47778971b06 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -321,6 +321,7 @@ function resolveWsRpc(tag: string): unknown { if (tag === WS_METHODS.gitListBranches) { return { isRepo: true, + hasOriginRemote: true, branches: [ { name: "main", diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 48edad37c0f..44ad29efac8 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -348,6 +348,21 @@ describe("when: working tree has local changes", () => { }); }); + it("resolveQuickAction falls back to commit when no origin remote exists", () => { + const quick = resolveQuickAction( + status({ hasWorkingTreeChanges: true, hasUpstream: false }), + false, + false, + false, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit", + label: "Commit", + disabled: false, + }); + }); + it("resolveQuickAction returns commit and push when open PR exists", () => { const quick = resolveQuickAction( status({ @@ -623,6 +638,25 @@ describe("when: branch has no upstream configured", () => { }); }); + it("resolveQuickAction disables push-and-pr flows when no origin remote exists", () => { + const quick = resolveQuickAction( + status({ + hasUpstream: false, + aheadCount: 2, + pr: null, + }), + false, + false, + false, + ); + assert.deepEqual(quick, { + kind: "show_hint", + label: "Push", + hint: 'Add an "origin" remote before pushing or creating a PR.', + disabled: true, + }); + }); + it("buildMenuItems enables create PR when no upstream and commits are ahead", () => { const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 2 }), false); assert.deepEqual(items, [ @@ -653,6 +687,40 @@ describe("when: branch has no upstream configured", () => { ]); }); + it("buildMenuItems disables push and create PR when no origin remote exists", () => { + const items = buildMenuItems( + status({ hasUpstream: false, pr: null, aheadCount: 2 }), + 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", + }, + ]); + }); + it("resolveQuickAction is disabled on default branch when no upstream exists and no commits are ahead", () => { const quick = resolveQuickAction( status({ diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 0aa8e9cd0d3..8f7f023ef77 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -113,6 +113,7 @@ export function summarizeGitResult(result: GitRunStackedActionResult): { export function buildMenuItems( gitStatus: GitStatusResult | null, isBusy: boolean, + hasOriginRemote = true, ): GitActionMenuItem[] { if (!gitStatus) return []; @@ -120,10 +121,23 @@ export function buildMenuItems( const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isBehind = gitStatus.behindCount > 0; + const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; const canCommit = !isBusy && hasChanges; - const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0; + const canPush = + !isBusy && + hasBranch && + !hasChanges && + !isBehind && + gitStatus.aheadCount > 0 && + (gitStatus.hasUpstream || canPushWithoutUpstream); const canCreatePr = - !isBusy && hasBranch && !hasChanges && !hasOpenPr && gitStatus.aheadCount > 0 && !isBehind; + !isBusy && + hasBranch && + !hasChanges && + !hasOpenPr && + gitStatus.aheadCount > 0 && + !isBehind && + (gitStatus.hasUpstream || canPushWithoutUpstream); const canOpenPr = !isBusy && hasOpenPr; return [ @@ -166,6 +180,7 @@ export function resolveQuickAction( gitStatus: GitStatusResult | null, isBusy: boolean, isDefaultBranch = false, + hasOriginRemote = true, ): GitQuickAction { if (isBusy) { return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; @@ -197,6 +212,9 @@ export function resolveQuickAction( } if (hasChanges) { + if (!gitStatus.hasUpstream && !hasOriginRemote) { + return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; + } if (hasOpenPr || isDefaultBranch) { return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; } @@ -209,6 +227,17 @@ export function resolveQuickAction( } if (!gitStatus.hasUpstream) { + if (!hasOriginRemote) { + if (hasOpenPr && !isAhead) { + return { label: "View PR", disabled: false, kind: "open_pr" }; + } + return { + label: "Push", + disabled: true, + kind: "show_hint", + hint: 'Add an "origin" remote before pushing or creating a PR.', + }; + } if (!isAhead) { if (hasOpenPr) { return { label: "View PR", disabled: false, kind: "open_pr" }; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index df4b18b234a..72723f3c550 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -59,11 +59,17 @@ interface PendingDefaultBranchAction { type GitActionToastId = ReturnType; -function getMenuActionDisabledReason( - item: GitActionMenuItem, - gitStatus: GitStatusResult | null, - isBusy: boolean, -): string | null { +function getMenuActionDisabledReason({ + item, + gitStatus, + isBusy, + hasOriginRemote, +}: { + item: GitActionMenuItem; + gitStatus: GitStatusResult | null; + isBusy: boolean; + hasOriginRemote: boolean; +}): string | null { if (!item.disabled) return null; if (isBusy) return "Git action in progress."; if (!gitStatus) return "Git status is unavailable."; @@ -91,6 +97,9 @@ function getMenuActionDisabledReason( if (isBehind) { return "Branch is behind upstream. Pull/rebase before pushing."; } + if (!gitStatus.hasUpstream && !hasOriginRemote) { + return 'Add an "origin" remote before pushing.'; + } if (!isAhead) { return "No local commits to push."; } @@ -106,6 +115,9 @@ function getMenuActionDisabledReason( if (hasChanges) { return "Commit local changes before creating a PR."; } + if (!gitStatus.hasUpstream && !hasOriginRemote) { + return 'Add an "origin" remote before creating a PR.'; + } if (!isAhead) { return "No local commits to include in a PR."; } @@ -154,6 +166,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); // Default to true while loading so we don't flash init controls. const isRepo = branchList?.isRepo ?? true; + const hasOriginRemote = branchList?.hasOriginRemote ?? false; const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; const isGitStatusOutOfSync = !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; @@ -184,12 +197,13 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }, [branchList?.branches, gitStatusForActions?.branch]); const gitActionMenuItems = useMemo( - () => buildMenuItems(gitStatusForActions, isGitActionRunning), - [gitStatusForActions, isGitActionRunning], + () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), + [gitStatusForActions, hasOriginRemote, isGitActionRunning], ); const quickAction = useMemo( - () => resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch), - [gitStatusForActions, isDefaultBranch, isGitActionRunning], + () => + resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote), + [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") @@ -645,11 +659,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions {gitActionMenuItems.map((item) => { - const disabledReason = getMenuActionDisabledReason( + const disabledReason = getMenuActionDisabledReason({ item, - gitStatusForActions, - isGitActionRunning, - ); + gitStatus: gitStatusForActions, + isBusy: isGitActionRunning, + hasOriginRemote, + }); if (item.disabled && disabledReason) { return ( diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index ae1eeeb5bf0..34ab11b16a6 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -149,6 +149,7 @@ export type GitStatusResult = typeof GitStatusResult.Type; export const GitListBranchesResult = Schema.Struct({ branches: Schema.Array(GitBranch), isRepo: Schema.Boolean, + hasOriginRemote: Schema.Boolean, }); export type GitListBranchesResult = typeof GitListBranchesResult.Type;