authorgravatar for crazywolf132@gmail.com0x1f99d <crazywolf132@gmail.com> 2026-03-11 04:16:37+11:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-10 10:16:37-07:00
log1c290767f22f9d8c7d3fa378d9450c8f48184679
tree66ac9b64f8517962269dcd70d471700fc8315499
parent9becb3f40af504244f85823bb19f18bfe507ca49
signature Signed by PGP key B5690EEEBB952194

fix: use commit as the default git action without origin (#642)

* fix(web): avoid push and PR defaults without origin * fmt * object here too --------- Co-authored-by: Julius Marminge <julius0216@outlook.com>

9 files changed, 229 insertions(+), 124 deletions(-)

apps/server/src/git/Layers/GitCore.test.ts+3
...@@ -260,6 +260,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -260,6 +260,7 @@ it.layer(TestLayer)("git integration", (it) => {
260 yield* initRepoWithCommit(tmp);260 yield* initRepoWithCommit(tmp);
261 const result = yield* listGitBranches({ cwd: tmp });261 const result = yield* listGitBranches({ cwd: tmp });
262 expect(result.isRepo).toBe(true);262 expect(result.isRepo).toBe(true);
263 expect(result.hasOriginRemote).toBe(false);
263 expect(result.branches.length).toBeGreaterThanOrEqual(1);264 expect(result.branches.length).toBeGreaterThanOrEqual(1);
264 }),265 }),
265 );266 );
...@@ -273,6 +274,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -273,6 +274,7 @@ it.layer(TestLayer)("git integration", (it) => {
273 const tmp = yield* makeTmpDir();274 const tmp = yield* makeTmpDir();
274 const result = yield* listGitBranches({ cwd: tmp });275 const result = yield* listGitBranches({ cwd: tmp });
275 expect(result.isRepo).toBe(false);276 expect(result.isRepo).toBe(false);
277 expect(result.hasOriginRemote).toBe(false);
276 expect(result.branches).toEqual([]);278 expect(result.branches).toEqual([]);
277 }),279 }),
278 );280 );
...@@ -431,6 +433,7 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -431,6 +433,7 @@ it.layer(TestLayer)("git integration", (it) => {
431 const result = yield* listGitBranches({ cwd: tmp });433 const result = yield* listGitBranches({ cwd: tmp });
432 const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote);434 const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote);
433435
436 expect(result.hasOriginRemote).toBe(true);
434 expect(firstRemoteIndex).toBeGreaterThan(0);437 expect(firstRemoteIndex).toBeGreaterThan(0);
435 expect(result.branches.slice(0, firstRemoteIndex).every((branch) => !branch.isRemote)).toBe(438 expect(result.branches.slice(0, firstRemoteIndex).every((branch) => !branch.isRemote)).toBe(
436 true,439 true,
apps/server/src/git/Layers/GitCore.ts+2-2
...@@ -931,7 +931,7 @@ const makeGitCore = Effect.gen(function* () {...@@ -931,7 +931,7 @@ const makeGitCore = Effect.gen(function* () {
931 if (localBranchResult.code !== 0) {931 if (localBranchResult.code !== 0) {
932 const stderr = localBranchResult.stderr.trim();932 const stderr = localBranchResult.stderr.trim();
933 if (stderr.toLowerCase().includes("not a git repository")) {933 if (stderr.toLowerCase().includes("not a git repository")) {
934 return { branches: [], isRepo: false };934 return { branches: [], isRepo: false, hasOriginRemote: false };
935 }935 }
936 return yield* createGitCommandError(936 return yield* createGitCommandError(
937 "GitCore.listBranches",937 "GitCore.listBranches",
...@@ -1097,7 +1097,7 @@ const makeGitCore = Effect.gen(function* () {...@@ -1097,7 +1097,7 @@ const makeGitCore = Effect.gen(function* () {
10971097
1098 const branches = [...localBranches, ...remoteBranches];1098 const branches = [...localBranches, ...remoteBranches];
10991099
1100 return { branches, isRepo: true };1100 return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") };
1101 });1101 });
11021102
1103 const createWorktree: GitCoreShape["createWorktree"] = (input) =>1103 const createWorktree: GitCoreShape["createWorktree"] = (input) =>
apps/server/src/wsServer.test.ts+2-1
...@@ -1577,6 +1577,7 @@ describe("WebSocket Server", () => {...@@ -1577,6 +1577,7 @@ describe("WebSocket Server", () => {
1577 Effect.succeed({1577 Effect.succeed({
1578 branches: [],1578 branches: [],
1579 isRepo: false,1579 isRepo: false,
1580 hasOriginRemote: false,
1580 }),1581 }),
1581 );1582 );
1582 const initRepo = vi.fn(() => Effect.void);1583 const initRepo = vi.fn(() => Effect.void);
...@@ -1608,7 +1609,7 @@ describe("WebSocket Server", () => {...@@ -1608,7 +1609,7 @@ describe("WebSocket Server", () => {
16081609
1609 const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" });1610 const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" });
1610 expect(listResponse.error).toBeUndefined();1611 expect(listResponse.error).toBeUndefined();
1611 expect(listResponse.result).toEqual({ branches: [], isRepo: false });1612 expect(listResponse.result).toEqual({ branches: [], isRepo: false, hasOriginRemote: false });
1612 expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" });1613 expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" });
16131614
1614 const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" });1615 const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" });
apps/web/public/mockServiceWorker.js+93-106
...@@ -7,114 +7,111 @@...@@ -7,114 +7,111 @@
7 * - Please do NOT modify this file.7 * - Please do NOT modify this file.
8 */8 */
99
10const PACKAGE_VERSION = '2.12.10'10const PACKAGE_VERSION = "2.12.10";
11const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'11const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82";
12const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')12const IS_MOCKED_RESPONSE = Symbol("isMockedResponse");
13const activeClientIds = new Set()13const activeClientIds = new Set();
1414
15addEventListener('install', function () {15addEventListener("install", function () {
16 self.skipWaiting()16 self.skipWaiting();
17})17});
1818
19addEventListener('activate', function (event) {19addEventListener("activate", function (event) {
20 event.waitUntil(self.clients.claim())20 event.waitUntil(self.clients.claim());
21})21});
2222
23addEventListener('message', async function (event) {23addEventListener("message", async function (event) {
24 const clientId = Reflect.get(event.source || {}, 'id')24 const clientId = Reflect.get(event.source || {}, "id");
2525
26 if (!clientId || !self.clients) {26 if (!clientId || !self.clients) {
27 return27 return;
28 }28 }
2929
30 const client = await self.clients.get(clientId)30 const client = await self.clients.get(clientId);
3131
32 if (!client) {32 if (!client) {
33 return33 return;
34 }34 }
3535
36 const allClients = await self.clients.matchAll({36 const allClients = await self.clients.matchAll({
37 type: 'window',37 type: "window",
38 })38 });
3939
40 switch (event.data) {40 switch (event.data) {
41 case 'KEEPALIVE_REQUEST': {41 case "KEEPALIVE_REQUEST": {
42 sendToClient(client, {42 sendToClient(client, {
43 type: 'KEEPALIVE_RESPONSE',43 type: "KEEPALIVE_RESPONSE",
44 })44 });
45 break45 break;
46 }46 }
4747
48 case 'INTEGRITY_CHECK_REQUEST': {48 case "INTEGRITY_CHECK_REQUEST": {
49 sendToClient(client, {49 sendToClient(client, {
50 type: 'INTEGRITY_CHECK_RESPONSE',50 type: "INTEGRITY_CHECK_RESPONSE",
51 payload: {51 payload: {
52 packageVersion: PACKAGE_VERSION,52 packageVersion: PACKAGE_VERSION,
53 checksum: INTEGRITY_CHECKSUM,53 checksum: INTEGRITY_CHECKSUM,
54 },54 },
55 })55 });
56 break56 break;
57 }57 }
5858
59 case 'MOCK_ACTIVATE': {59 case "MOCK_ACTIVATE": {
60 activeClientIds.add(clientId)60 activeClientIds.add(clientId);
6161
62 sendToClient(client, {62 sendToClient(client, {
63 type: 'MOCKING_ENABLED',63 type: "MOCKING_ENABLED",
64 payload: {64 payload: {
65 client: {65 client: {
66 id: client.id,66 id: client.id,
67 frameType: client.frameType,67 frameType: client.frameType,
68 },68 },
69 },69 },
70 })70 });
71 break71 break;
72 }72 }
7373
74 case 'CLIENT_CLOSED': {74 case "CLIENT_CLOSED": {
75 activeClientIds.delete(clientId)75 activeClientIds.delete(clientId);
7676
77 const remainingClients = allClients.filter((client) => {77 const remainingClients = allClients.filter((client) => {
78 return client.id !== clientId78 return client.id !== clientId;
79 })79 });
8080
81 // Unregister itself when there are no more clients81 // Unregister itself when there are no more clients
82 if (remainingClients.length === 0) {82 if (remainingClients.length === 0) {
83 self.registration.unregister()83 self.registration.unregister();
84 }84 }
8585
86 break86 break;
87 }87 }
88 }88 }
89})89});
9090
91addEventListener('fetch', function (event) {91addEventListener("fetch", function (event) {
92 const requestInterceptedAt = Date.now()92 const requestInterceptedAt = Date.now();
9393
94 // Bypass navigation requests.94 // Bypass navigation requests.
95 if (event.request.mode === 'navigate') {95 if (event.request.mode === "navigate") {
96 return96 return;
97 }97 }
9898
99 // Opening the DevTools triggers the "only-if-cached" request99 // Opening the DevTools triggers the "only-if-cached" request
100 // that cannot be handled by the worker. Bypass such requests.100 // that cannot be handled by the worker. Bypass such requests.
101 if (101 if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") {
102 event.request.cache === 'only-if-cached' &&102 return;
103 event.request.mode !== 'same-origin'
104 ) {
105 return
106 }103 }
107104
108 // Bypass all requests when there are no active clients.105 // Bypass all requests when there are no active clients.
109 // Prevents the self-unregistered worked from handling requests106 // Prevents the self-unregistered worked from handling requests
110 // after it's been terminated (still remains active until the next reload).107 // after it's been terminated (still remains active until the next reload).
111 if (activeClientIds.size === 0) {108 if (activeClientIds.size === 0) {
112 return109 return;
113 }110 }
114111
115 const requestId = crypto.randomUUID()112 const requestId = crypto.randomUUID();
116 event.respondWith(handleRequest(event, requestId, requestInterceptedAt))113 event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
117})114});
118115
119/**116/**
120 * @param {FetchEvent} event117 * @param {FetchEvent} event
...@@ -122,28 +119,23 @@ addEventListener('fetch', function (event) {...@@ -122,28 +119,23 @@ addEventListener('fetch', function (event) {
122 * @param {number} requestInterceptedAt119 * @param {number} requestInterceptedAt
123 */120 */
124async function handleRequest(event, requestId, requestInterceptedAt) {121async function handleRequest(event, requestId, requestInterceptedAt) {
125 const client = await resolveMainClient(event)122 const client = await resolveMainClient(event);
126 const requestCloneForEvents = event.request.clone()123 const requestCloneForEvents = event.request.clone();
127 const response = await getResponse(124 const response = await getResponse(event, client, requestId, requestInterceptedAt);
128 event,
129 client,
130 requestId,
131 requestInterceptedAt,
132 )
133125
134 // Send back the response clone for the "response:*" life-cycle events.126 // Send back the response clone for the "response:*" life-cycle events.
135 // Ensure MSW is active and ready to handle the message, otherwise127 // Ensure MSW is active and ready to handle the message, otherwise
136 // this message will pend indefinitely.128 // this message will pend indefinitely.
137 if (client && activeClientIds.has(client.id)) {129 if (client && activeClientIds.has(client.id)) {
138 const serializedRequest = await serializeRequest(requestCloneForEvents)130 const serializedRequest = await serializeRequest(requestCloneForEvents);
139131
140 // Clone the response so both the client and the library could consume it.132 // Clone the response so both the client and the library could consume it.
141 const responseClone = response.clone()133 const responseClone = response.clone();
142134
143 sendToClient(135 sendToClient(
144 client,136 client,
145 {137 {
146 type: 'RESPONSE',138 type: "RESPONSE",
147 payload: {139 payload: {
148 isMockedResponse: IS_MOCKED_RESPONSE in response,140 isMockedResponse: IS_MOCKED_RESPONSE in response,
149 request: {141 request: {
...@@ -160,10 +152,10 @@ async function handleRequest(event, requestId, requestInterceptedAt) {...@@ -160,10 +152,10 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
160 },152 },
161 },153 },
162 responseClone.body ? [serializedRequest.body, responseClone.body] : [],154 responseClone.body ? [serializedRequest.body, responseClone.body] : [],
163 )155 );
164 }156 }
165157
166 return response158 return response;
167}159}
168160
169/**161/**
...@@ -175,30 +167,30 @@ async function handleRequest(event, requestId, requestInterceptedAt) {...@@ -175,30 +167,30 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
175 * @returns {Promise<Client | undefined>}167 * @returns {Promise<Client | undefined>}
176 */168 */
177async function resolveMainClient(event) {169async function resolveMainClient(event) {
178 const client = await self.clients.get(event.clientId)170 const client = await self.clients.get(event.clientId);
179171
180 if (activeClientIds.has(event.clientId)) {172 if (activeClientIds.has(event.clientId)) {
181 return client173 return client;
182 }174 }
183175
184 if (client?.frameType === 'top-level') {176 if (client?.frameType === "top-level") {
185 return client177 return client;
186 }178 }
187179
188 const allClients = await self.clients.matchAll({180 const allClients = await self.clients.matchAll({
189 type: 'window',181 type: "window",
190 })182 });
191183
192 return allClients184 return allClients
193 .filter((client) => {185 .filter((client) => {
194 // Get only those clients that are currently visible.186 // Get only those clients that are currently visible.
195 return client.visibilityState === 'visible'187 return client.visibilityState === "visible";
196 })188 })
197 .find((client) => {189 .find((client) => {
198 // Find the client ID that's recorded in the190 // Find the client ID that's recorded in the
199 // set of clients that have registered the worker.191 // set of clients that have registered the worker.
200 return activeClientIds.has(client.id)192 return activeClientIds.has(client.id);
201 })193 });
202}194}
203195
204/**196/**
...@@ -211,36 +203,34 @@ async function resolveMainClient(event) {...@@ -211,36 +203,34 @@ async function resolveMainClient(event) {
211async function getResponse(event, client, requestId, requestInterceptedAt) {203async function getResponse(event, client, requestId, requestInterceptedAt) {
212 // Clone the request because it might've been already used204 // Clone the request because it might've been already used
213 // (i.e. its body has been read and sent to the client).205 // (i.e. its body has been read and sent to the client).
214 const requestClone = event.request.clone()206 const requestClone = event.request.clone();
215207
216 function passthrough() {208 function passthrough() {
217 // Cast the request headers to a new Headers instance209 // Cast the request headers to a new Headers instance
218 // so the headers can be manipulated with.210 // so the headers can be manipulated with.
219 const headers = new Headers(requestClone.headers)211 const headers = new Headers(requestClone.headers);
220212
221 // Remove the "accept" header value that marked this request as passthrough.213 // Remove the "accept" header value that marked this request as passthrough.
222 // This prevents request alteration and also keeps it compliant with the214 // This prevents request alteration and also keeps it compliant with the
223 // user-defined CORS policies.215 // user-defined CORS policies.
224 const acceptHeader = headers.get('accept')216 const acceptHeader = headers.get("accept");
225 if (acceptHeader) {217 if (acceptHeader) {
226 const values = acceptHeader.split(',').map((value) => value.trim())218 const values = acceptHeader.split(",").map((value) => value.trim());
227 const filteredValues = values.filter(219 const filteredValues = values.filter((value) => value !== "msw/passthrough");
228 (value) => value !== 'msw/passthrough',
229 )
230220
231 if (filteredValues.length > 0) {221 if (filteredValues.length > 0) {
232 headers.set('accept', filteredValues.join(', '))222 headers.set("accept", filteredValues.join(", "));
233 } else {223 } else {
234 headers.delete('accept')224 headers.delete("accept");
235 }225 }
236 }226 }
237227
238 return fetch(requestClone, { headers })228 return fetch(requestClone, { headers });
239 }229 }
240230
241 // Bypass mocking when the client is not active.231 // Bypass mocking when the client is not active.
242 if (!client) {232 if (!client) {
243 return passthrough()233 return passthrough();
244 }234 }
245235
246 // Bypass initial page load requests (i.e. static assets).236 // Bypass initial page load requests (i.e. static assets).
...@@ -248,15 +238,15 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {...@@ -248,15 +238,15 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
248 // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet238 // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
249 // and is not ready to handle requests.239 // and is not ready to handle requests.
250 if (!activeClientIds.has(client.id)) {240 if (!activeClientIds.has(client.id)) {
251 return passthrough()241 return passthrough();
252 }242 }
253243
254 // Notify the client that a request has been intercepted.244 // Notify the client that a request has been intercepted.
255 const serializedRequest = await serializeRequest(event.request)245 const serializedRequest = await serializeRequest(event.request);
256 const clientMessage = await sendToClient(246 const clientMessage = await sendToClient(
257 client,247 client,
258 {248 {
259 type: 'REQUEST',249 type: "REQUEST",
260 payload: {250 payload: {
261 id: requestId,251 id: requestId,
262 interceptedAt: requestInterceptedAt,252 interceptedAt: requestInterceptedAt,
...@@ -264,19 +254,19 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {...@@ -264,19 +254,19 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
264 },254 },
265 },255 },
266 [serializedRequest.body],256 [serializedRequest.body],
267 )257 );
268258
269 switch (clientMessage.type) {259 switch (clientMessage.type) {
270 case 'MOCK_RESPONSE': {260 case "MOCK_RESPONSE": {
271 return respondWithMock(clientMessage.data)261 return respondWithMock(clientMessage.data);
272 }262 }
273263
274 case 'PASSTHROUGH': {264 case "PASSTHROUGH": {
275 return passthrough()265 return passthrough();
276 }266 }
277 }267 }
278268
279 return passthrough()269 return passthrough();
280}270}
281271
282/**272/**
...@@ -287,21 +277,18 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {...@@ -287,21 +277,18 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
287 */277 */
288function sendToClient(client, message, transferrables = []) {278function sendToClient(client, message, transferrables = []) {
289 return new Promise((resolve, reject) => {279 return new Promise((resolve, reject) => {
290 const channel = new MessageChannel()280 const channel = new MessageChannel();
291281
292 channel.port1.onmessage = (event) => {282 channel.port1.onmessage = (event) => {
293 if (event.data && event.data.error) {283 if (event.data && event.data.error) {
294 return reject(event.data.error)284 return reject(event.data.error);
295 }285 }
296286
297 resolve(event.data)287 resolve(event.data);
298 }288 };
299289
300 client.postMessage(message, [290 client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
301 channel.port2,291 });
302 ...transferrables.filter(Boolean),
303 ])
304 })
305}292}
306293
307/**294/**
...@@ -314,17 +301,17 @@ function respondWithMock(response) {...@@ -314,17 +301,17 @@ function respondWithMock(response) {
314 // instance will have status code set to 0. Since it's not possible to create301 // instance will have status code set to 0. Since it's not possible to create
315 // a Response instance with status code 0, handle that use-case separately.302 // a Response instance with status code 0, handle that use-case separately.
316 if (response.status === 0) {303 if (response.status === 0) {
317 return Response.error()304 return Response.error();
318 }305 }
319306
320 const mockedResponse = new Response(response.body, response)307 const mockedResponse = new Response(response.body, response);
321308
322 Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {309 Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
323 value: true,310 value: true,
324 enumerable: true,311 enumerable: true,
325 })312 });
326313
327 return mockedResponse314 return mockedResponse;
328}315}
329316
330/**317/**
...@@ -345,5 +332,5 @@ async function serializeRequest(request) {...@@ -345,5 +332,5 @@ async function serializeRequest(request) {
345 referrerPolicy: request.referrerPolicy,332 referrerPolicy: request.referrerPolicy,
346 body: await request.arrayBuffer(),333 body: await request.arrayBuffer(),
347 keepalive: request.keepalive,334 keepalive: request.keepalive,
348 }335 };
349}336}
apps/web/src/components/ChatView.browser.tsx+1
...@@ -321,6 +321,7 @@ function resolveWsRpc(tag: string): unknown {...@@ -321,6 +321,7 @@ function resolveWsRpc(tag: string): unknown {
321 if (tag === WS_METHODS.gitListBranches) {321 if (tag === WS_METHODS.gitListBranches) {
322 return {322 return {
323 isRepo: true,323 isRepo: true,
324 hasOriginRemote: true,
324 branches: [325 branches: [
325 {326 {
326 name: "main",327 name: "main",
apps/web/src/components/GitActionsControl.logic.test.ts+68
...@@ -348,6 +348,21 @@ describe("when: working tree has local changes", () => {...@@ -348,6 +348,21 @@ describe("when: working tree has local changes", () => {
348 });348 });
349 });349 });
350350
351 it("resolveQuickAction falls back to commit when no origin remote exists", () => {
352 const quick = resolveQuickAction(
353 status({ hasWorkingTreeChanges: true, hasUpstream: false }),
354 false,
355 false,
356 false,
357 );
358 assert.deepInclude(quick, {
359 kind: "run_action",
360 action: "commit",
361 label: "Commit",
362 disabled: false,
363 });
364 });
365
351 it("resolveQuickAction returns commit and push when open PR exists", () => {366 it("resolveQuickAction returns commit and push when open PR exists", () => {
352 const quick = resolveQuickAction(367 const quick = resolveQuickAction(
353 status({368 status({
...@@ -623,6 +638,25 @@ describe("when: branch has no upstream configured", () => {...@@ -623,6 +638,25 @@ describe("when: branch has no upstream configured", () => {
623 });638 });
624 });639 });
625640
641 it("resolveQuickAction disables push-and-pr flows when no origin remote exists", () => {
642 const quick = resolveQuickAction(
643 status({
644 hasUpstream: false,
645 aheadCount: 2,
646 pr: null,
647 }),
648 false,
649 false,
650 false,
651 );
652 assert.deepEqual(quick, {
653 kind: "show_hint",
654 label: "Push",
655 hint: 'Add an "origin" remote before pushing or creating a PR.',
656 disabled: true,
657 });
658 });
659
626 it("buildMenuItems enables create PR when no upstream and commits are ahead", () => {660 it("buildMenuItems enables create PR when no upstream and commits are ahead", () => {
627 const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 2 }), false);661 const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 2 }), false);
628 assert.deepEqual(items, [662 assert.deepEqual(items, [
...@@ -653,6 +687,40 @@ describe("when: branch has no upstream configured", () => {...@@ -653,6 +687,40 @@ describe("when: branch has no upstream configured", () => {
653 ]);687 ]);
654 });688 });
655689
690 it("buildMenuItems disables push and create PR when no origin remote exists", () => {
691 const items = buildMenuItems(
692 status({ hasUpstream: false, pr: null, aheadCount: 2 }),
693 false,
694 false,
695 );
696 assert.deepEqual(items, [
697 {
698 id: "commit",
699 label: "Commit",
700 disabled: true,
701 icon: "commit",
702 kind: "open_dialog",
703 dialogAction: "commit",
704 },
705 {
706 id: "push",
707 label: "Push",
708 disabled: true,
709 icon: "push",
710 kind: "open_dialog",
711 dialogAction: "push",
712 },
713 {
714 id: "pr",
715 label: "Create PR",
716 disabled: true,
717 icon: "pr",
718 kind: "open_dialog",
719 dialogAction: "create_pr",
720 },
721 ]);
722 });
723
656 it("resolveQuickAction is disabled on default branch when no upstream exists and no commits are ahead", () => {724 it("resolveQuickAction is disabled on default branch when no upstream exists and no commits are ahead", () => {
657 const quick = resolveQuickAction(725 const quick = resolveQuickAction(
658 status({726 status({
apps/web/src/components/GitActionsControl.logic.ts+31-2
...@@ -113,6 +113,7 @@ export function summarizeGitResult(result: GitRunStackedActionResult): {...@@ -113,6 +113,7 @@ export function summarizeGitResult(result: GitRunStackedActionResult): {
113export function buildMenuItems(113export function buildMenuItems(
114 gitStatus: GitStatusResult | null,114 gitStatus: GitStatusResult | null,
115 isBusy: boolean,115 isBusy: boolean,
116 hasOriginRemote = true,
116): GitActionMenuItem[] {117): GitActionMenuItem[] {
117 if (!gitStatus) return [];118 if (!gitStatus) return [];
118119
...@@ -120,10 +121,23 @@ export function buildMenuItems(...@@ -120,10 +121,23 @@ export function buildMenuItems(
120 const hasChanges = gitStatus.hasWorkingTreeChanges;121 const hasChanges = gitStatus.hasWorkingTreeChanges;
121 const hasOpenPr = gitStatus.pr?.state === "open";122 const hasOpenPr = gitStatus.pr?.state === "open";
122 const isBehind = gitStatus.behindCount > 0;123 const isBehind = gitStatus.behindCount > 0;
124 const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream;
123 const canCommit = !isBusy && hasChanges;125 const canCommit = !isBusy && hasChanges;
124 const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0;126 const canPush =
127 !isBusy &&
128 hasBranch &&
129 !hasChanges &&
130 !isBehind &&
131 gitStatus.aheadCount > 0 &&
132 (gitStatus.hasUpstream || canPushWithoutUpstream);
125 const canCreatePr =133 const canCreatePr =
126 !isBusy && hasBranch && !hasChanges && !hasOpenPr && gitStatus.aheadCount > 0 && !isBehind;134 !isBusy &&
135 hasBranch &&
136 !hasChanges &&
137 !hasOpenPr &&
138 gitStatus.aheadCount > 0 &&
139 !isBehind &&
140 (gitStatus.hasUpstream || canPushWithoutUpstream);
127 const canOpenPr = !isBusy && hasOpenPr;141 const canOpenPr = !isBusy && hasOpenPr;
128142
129 return [143 return [
...@@ -166,6 +180,7 @@ export function resolveQuickAction(...@@ -166,6 +180,7 @@ export function resolveQuickAction(
166 gitStatus: GitStatusResult | null,180 gitStatus: GitStatusResult | null,
167 isBusy: boolean,181 isBusy: boolean,
168 isDefaultBranch = false,182 isDefaultBranch = false,
183 hasOriginRemote = true,
169): GitQuickAction {184): GitQuickAction {
170 if (isBusy) {185 if (isBusy) {
171 return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." };186 return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." };
...@@ -197,6 +212,9 @@ export function resolveQuickAction(...@@ -197,6 +212,9 @@ export function resolveQuickAction(
197 }212 }
198213
199 if (hasChanges) {214 if (hasChanges) {
215 if (!gitStatus.hasUpstream && !hasOriginRemote) {
216 return { label: "Commit", disabled: false, kind: "run_action", action: "commit" };
217 }
200 if (hasOpenPr || isDefaultBranch) {218 if (hasOpenPr || isDefaultBranch) {
201 return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" };219 return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" };
202 }220 }
...@@ -209,6 +227,17 @@ export function resolveQuickAction(...@@ -209,6 +227,17 @@ export function resolveQuickAction(
209 }227 }
210228
211 if (!gitStatus.hasUpstream) {229 if (!gitStatus.hasUpstream) {
230 if (!hasOriginRemote) {
231 if (hasOpenPr && !isAhead) {
232 return { label: "View PR", disabled: false, kind: "open_pr" };
233 }
234 return {
235 label: "Push",
236 disabled: true,
237 kind: "show_hint",
238 hint: 'Add an "origin" remote before pushing or creating a PR.',
239 };
240 }
212 if (!isAhead) {241 if (!isAhead) {
213 if (hasOpenPr) {242 if (hasOpenPr) {
214 return { label: "View PR", disabled: false, kind: "open_pr" };243 return { label: "View PR", disabled: false, kind: "open_pr" };
apps/web/src/components/GitActionsControl.tsx+28-13
...@@ -59,11 +59,17 @@ interface PendingDefaultBranchAction {...@@ -59,11 +59,17 @@ interface PendingDefaultBranchAction {
5959
60type GitActionToastId = ReturnType<typeof toastManager.add>;60type GitActionToastId = ReturnType<typeof toastManager.add>;
6161
62function getMenuActionDisabledReason(62function getMenuActionDisabledReason({
63 item: GitActionMenuItem,63 item,
64 gitStatus: GitStatusResult | null,64 gitStatus,
65 isBusy: boolean,65 isBusy,
66): string | null {66 hasOriginRemote,
67}: {
68 item: GitActionMenuItem;
69 gitStatus: GitStatusResult | null;
70 isBusy: boolean;
71 hasOriginRemote: boolean;
72}): string | null {
67 if (!item.disabled) return null;73 if (!item.disabled) return null;
68 if (isBusy) return "Git action in progress.";74 if (isBusy) return "Git action in progress.";
69 if (!gitStatus) return "Git status is unavailable.";75 if (!gitStatus) return "Git status is unavailable.";
...@@ -91,6 +97,9 @@ function getMenuActionDisabledReason(...@@ -91,6 +97,9 @@ function getMenuActionDisabledReason(
91 if (isBehind) {97 if (isBehind) {
92 return "Branch is behind upstream. Pull/rebase before pushing.";98 return "Branch is behind upstream. Pull/rebase before pushing.";
93 }99 }
100 if (!gitStatus.hasUpstream && !hasOriginRemote) {
101 return 'Add an "origin" remote before pushing.';
102 }
94 if (!isAhead) {103 if (!isAhead) {
95 return "No local commits to push.";104 return "No local commits to push.";
96 }105 }
...@@ -106,6 +115,9 @@ function getMenuActionDisabledReason(...@@ -106,6 +115,9 @@ function getMenuActionDisabledReason(
106 if (hasChanges) {115 if (hasChanges) {
107 return "Commit local changes before creating a PR.";116 return "Commit local changes before creating a PR.";
108 }117 }
118 if (!gitStatus.hasUpstream && !hasOriginRemote) {
119 return 'Add an "origin" remote before creating a PR.';
120 }
109 if (!isAhead) {121 if (!isAhead) {
110 return "No local commits to include in a PR.";122 return "No local commits to include in a PR.";
111 }123 }
...@@ -154,6 +166,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -154,6 +166,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
154 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd));166 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd));
155 // Default to true while loading so we don't flash init controls.167 // Default to true while loading so we don't flash init controls.
156 const isRepo = branchList?.isRepo ?? true;168 const isRepo = branchList?.isRepo ?? true;
169 const hasOriginRemote = branchList?.hasOriginRemote ?? false;
157 const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null;170 const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null;
158 const isGitStatusOutOfSync =171 const isGitStatusOutOfSync =
159 !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch;172 !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch;
...@@ -184,12 +197,13 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -184,12 +197,13 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
184 }, [branchList?.branches, gitStatusForActions?.branch]);197 }, [branchList?.branches, gitStatusForActions?.branch]);
185198
186 const gitActionMenuItems = useMemo(199 const gitActionMenuItems = useMemo(
187 () => buildMenuItems(gitStatusForActions, isGitActionRunning),200 () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote),
188 [gitStatusForActions, isGitActionRunning],201 [gitStatusForActions, hasOriginRemote, isGitActionRunning],
189 );202 );
190 const quickAction = useMemo(203 const quickAction = useMemo(
191 () => resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch),204 () =>
192 [gitStatusForActions, isDefaultBranch, isGitActionRunning],205 resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote),
206 [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning],
193 );207 );
194 const quickActionDisabledReason = quickAction.disabled208 const quickActionDisabledReason = quickAction.disabled
195 ? (quickAction.hint ?? "This action is currently unavailable.")209 ? (quickAction.hint ?? "This action is currently unavailable.")
...@@ -645,11 +659,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -645,11 +659,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
645 </MenuTrigger>659 </MenuTrigger>
646 <MenuPopup align="end" className="w-full">660 <MenuPopup align="end" className="w-full">
647 {gitActionMenuItems.map((item) => {661 {gitActionMenuItems.map((item) => {
648 const disabledReason = getMenuActionDisabledReason(662 const disabledReason = getMenuActionDisabledReason({
649 item,663 item,
650 gitStatusForActions,664 gitStatus: gitStatusForActions,
651 isGitActionRunning,665 isBusy: isGitActionRunning,
652 );666 hasOriginRemote,
667 });
653 if (item.disabled && disabledReason) {668 if (item.disabled && disabledReason) {
654 return (669 return (
655 <Popover key={`${item.id}-${item.label}`}>670 <Popover key={`${item.id}-${item.label}`}>
packages/contracts/src/git.ts+1
...@@ -149,6 +149,7 @@ export type GitStatusResult = typeof GitStatusResult.Type;...@@ -149,6 +149,7 @@ export type GitStatusResult = typeof GitStatusResult.Type;
149export const GitListBranchesResult = Schema.Struct({149export const GitListBranchesResult = Schema.Struct({
150 branches: Schema.Array(GitBranch),150 branches: Schema.Array(GitBranch),
151 isRepo: Schema.Boolean,151 isRepo: Schema.Boolean,
152 hasOriginRemote: Schema.Boolean,
152});153});
153export type GitListBranchesResult = typeof GitListBranchesResult.Type;154export type GitListBranchesResult = typeof GitListBranchesResult.Type;
154155