authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-14 22:36:16-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-14 23:35:48-08:00
log85eb379dd769db2f949a3352acca6537b02e89cf
treeceb261480dcdfbca53b79335c54106dba7184b89
parent5a30594009cc1170cd181649899c86b5452eff15

Improve git status details and streamline quick action flows

- add working-tree file/line stats to git status contracts, server responses, and tests - refactor web git queries/mutations into shared React Query helpers - update git quick-action logic/UI to prefer commit/push/PR flows and clearer disabled states - hide branch toolbar for non-repos and tighten related chat/toolbar behavior

12 files changed, 871 insertions(+), 353 deletions(-)

apps/server/src/git.ts+85-2
...@@ -94,6 +94,51 @@ function parseBranchAb(value: string): { ahead: number; behind: number } {...@@ -94,6 +94,51 @@ function parseBranchAb(value: string): { ahead: number; behind: number } {
94 };94 };
95}95}
9696
97function parseNumstatEntries(
98 stdout: string,
99): Array<{ path: string; insertions: number; deletions: number }> {
100 const entries: Array<{ path: string; insertions: number; deletions: number }> = [];
101 for (const line of stdout.split(/\r?\n/g)) {
102 if (line.trim().length === 0) continue;
103 const [addedRaw, deletedRaw, ...pathParts] = line.split("\t");
104 const rawPath = pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim();
105 if (rawPath.length === 0) continue;
106 const added = Number.parseInt(addedRaw ?? "0", 10);
107 const deleted = Number.parseInt(deletedRaw ?? "0", 10);
108 const renameArrowIndex = rawPath.indexOf(" => ");
109 const normalizedPath =
110 renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath;
111 entries.push({
112 path: normalizedPath.length > 0 ? normalizedPath : rawPath,
113 insertions: Number.isFinite(added) ? added : 0,
114 deletions: Number.isFinite(deleted) ? deleted : 0,
115 });
116 }
117 return entries;
118}
119
120function parsePorcelainPath(line: string): string | null {
121 if (line.startsWith("? ") || line.startsWith("! ")) {
122 const simple = line.slice(2).trim();
123 return simple.length > 0 ? simple : null;
124 }
125
126 if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) {
127 return null;
128 }
129
130 const tabIndex = line.indexOf("\t");
131 if (tabIndex >= 0) {
132 const fromTab = line.slice(tabIndex + 1);
133 const [path] = fromTab.split("\t");
134 return path?.trim().length ? path.trim() : null;
135 }
136
137 const parts = line.trim().split(/\s+/g);
138 const path = parts.at(-1) ?? "";
139 return path.length > 0 ? path : null;
140}
141
97function commandLabel(args: readonly string[]): string {142function commandLabel(args: readonly string[]): string {
98 return `git ${args.join(" ")}`;143 return `git ${args.join(" ")}`;
99}144}
...@@ -176,6 +221,7 @@ export class GitCoreService {...@@ -176,6 +221,7 @@ export class GitCoreService {
176 return gitStatusResultSchema.parse({221 return gitStatusResultSchema.parse({
177 branch: details.branch,222 branch: details.branch,
178 hasWorkingTreeChanges: details.hasWorkingTreeChanges,223 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
224 workingTree: details.workingTree,
179 hasUpstream: details.hasUpstream,225 hasUpstream: details.hasUpstream,
180 aheadCount: details.aheadCount,226 aheadCount: details.aheadCount,
181 behindCount: details.behindCount,227 behindCount: details.behindCount,
...@@ -184,15 +230,20 @@ export class GitCoreService {...@@ -184,15 +230,20 @@ export class GitCoreService {
184 }230 }
185231
186 async statusDetails(cwd: string): Promise<GitStatusDetails> {232 async statusDetails(cwd: string): Promise<GitStatusDetails> {
187 const stdout = await this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]);233 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([
234 this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]),
235 this.gitStdout(cwd, ["diff", "--numstat"]),
236 this.gitStdout(cwd, ["diff", "--cached", "--numstat"]),
237 ]);
188238
189 let branch: string | null = null;239 let branch: string | null = null;
190 let upstreamRef: string | null = null;240 let upstreamRef: string | null = null;
191 let aheadCount = 0;241 let aheadCount = 0;
192 let behindCount = 0;242 let behindCount = 0;
193 let hasWorkingTreeChanges = false;243 let hasWorkingTreeChanges = false;
244 const changedFilesWithoutNumstat = new Set<string>();
194245
195 for (const line of stdout.split(/\r?\n/g)) {246 for (const line of statusStdout.split(/\r?\n/g)) {
196 if (line.startsWith("# branch.head ")) {247 if (line.startsWith("# branch.head ")) {
197 const value = line.slice("# branch.head ".length).trim();248 const value = line.slice("# branch.head ".length).trim();
198 branch = value.startsWith("(") ? null : value;249 branch = value.startsWith("(") ? null : value;
...@@ -212,13 +263,45 @@ export class GitCoreService {...@@ -212,13 +263,45 @@ export class GitCoreService {
212 }263 }
213 if (line.trim().length > 0 && !line.startsWith("#")) {264 if (line.trim().length > 0 && !line.startsWith("#")) {
214 hasWorkingTreeChanges = true;265 hasWorkingTreeChanges = true;
266 const pathValue = parsePorcelainPath(line);
267 if (pathValue) changedFilesWithoutNumstat.add(pathValue);
215 }268 }
216 }269 }
270 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);
271 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);
272 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();
273 for (const entry of [...stagedEntries, ...unstagedEntries]) {
274 const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 };
275 existing.insertions += entry.insertions;
276 existing.deletions += entry.deletions;
277 fileStatMap.set(entry.path, existing);
278 }
279
280 let insertions = 0;
281 let deletions = 0;
282 const files = Array.from(fileStatMap.entries())
283 .map(([path, stat]) => {
284 insertions += stat.insertions;
285 deletions += stat.deletions;
286 return { path, insertions: stat.insertions, deletions: stat.deletions };
287 })
288 .toSorted((a, b) => a.path.localeCompare(b.path));
289
290 for (const filePath of changedFilesWithoutNumstat) {
291 if (fileStatMap.has(filePath)) continue;
292 files.push({ path: filePath, insertions: 0, deletions: 0 });
293 }
294 files.sort((a, b) => a.path.localeCompare(b.path));
217295
218 return {296 return {
219 branch,297 branch,
220 upstreamRef,298 upstreamRef,
221 hasWorkingTreeChanges,299 hasWorkingTreeChanges,
300 workingTree: {
301 files,
302 insertions,
303 deletions,
304 },
222 hasUpstream: upstreamRef !== null,305 hasUpstream: upstreamRef !== null,
223 aheadCount,306 aheadCount,
224 behindCount,307 behindCount,
apps/server/src/gitManager.ts+1
...@@ -218,6 +218,7 @@ export class GitManager {...@@ -218,6 +218,7 @@ export class GitManager {
218 return {218 return {
219 branch: details.branch,219 branch: details.branch,
220 hasWorkingTreeChanges: details.hasWorkingTreeChanges,220 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
221 workingTree: details.workingTree,
221 hasUpstream: details.hasUpstream,222 hasUpstream: details.hasUpstream,
222 aheadCount: details.aheadCount,223 aheadCount: details.aheadCount,
223 behindCount: details.behindCount,224 behindCount: details.behindCount,
apps/server/src/wsServer.test.ts+10
...@@ -557,6 +557,11 @@ describe("WebSocket Server", () => {...@@ -557,6 +557,11 @@ describe("WebSocket Server", () => {
557 status: vi.fn().mockResolvedValue({557 status: vi.fn().mockResolvedValue({
558 branch: "feature/test",558 branch: "feature/test",
559 hasWorkingTreeChanges: true,559 hasWorkingTreeChanges: true,
560 workingTree: {
561 files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }],
562 insertions: 7,
563 deletions: 2,
564 },
560 hasUpstream: false,565 hasUpstream: false,
561 aheadCount: 0,566 aheadCount: 0,
562 behindCount: 0,567 behindCount: 0,
...@@ -581,6 +586,11 @@ describe("WebSocket Server", () => {...@@ -581,6 +586,11 @@ describe("WebSocket Server", () => {
581 expect(response.result).toEqual({586 expect(response.result).toEqual({
582 branch: "feature/test",587 branch: "feature/test",
583 hasWorkingTreeChanges: true,588 hasWorkingTreeChanges: true,
589 workingTree: {
590 files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }],
591 insertions: 7,
592 deletions: 2,
593 },
584 hasUpstream: false,594 hasUpstream: false,
585 aheadCount: 0,595 aheadCount: 0,
586 behindCount: 0,596 behindCount: 0,
apps/web/src/components/BranchToolbar.tsx+118-152
...@@ -3,6 +3,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";...@@ -3,6 +3,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useEffect, useState } from "react";3import { useEffect, useState } from "react";
44
5import { useNativeApi } from "../hooks/useNativeApi";5import { useNativeApi } from "../hooks/useNativeApi";
6import {
7 gitBranchesQueryOptions,
8 gitCheckoutMutationOptions,
9 gitCreateBranchAndCheckoutMutationOptions,
10} from "../lib/gitReactQuery";
6import { useStore } from "../store";11import { useStore } from "../store";
7import { Button } from "./ui/button";12import { Button } from "./ui/button";
8import {13import {
...@@ -37,28 +42,19 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -37,28 +42,19 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
37 const activeThreadId = activeThread?.id;42 const activeThreadId = activeThread?.id;
38 const activeThreadBranch = activeThread?.branch ?? null;43 const activeThreadBranch = activeThread?.branch ?? null;
39 const activeWorktreePath = activeThread?.worktreePath ?? null;44 const activeWorktreePath = activeThread?.worktreePath ?? null;
40 const branchCwd = activeWorktreePath ?? activeProject?.cwd;45 const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null;
4146
42 // ── Queries ───────────────────────────────────────────────────────────47 // ── Queries ───────────────────────────────────────────────────────────
4348
44 const branchesQuery = useQuery({49 const branchesQuery = useQuery(gitBranchesQueryOptions(api, branchCwd));
45 queryKey: ["git", "branches", branchCwd],
46 queryFn: () => api!.git.listBranches({ cwd: branchCwd! }),
47 enabled: !!api && !!branchCwd,
48 });
4950
50 const branches = branchesQuery.data?.branches ?? [];51 const branches = branchesQuery.data?.branches ?? [];
51 const branchNames = branches.map((branch) => branch.name);52 const branchNames = branches.map((branch) => branch.name);
52 const branchByName = new Map(branches.map((branch) => [branch.name, branch]));53 const branchByName = new Map(branches.map((branch) => [branch.name, branch]));
53 // Default to true while loading — showing "Initialize git" during a fetch is wrong,
54 // and worktrees are inherently git repos.
55 const isRepo = branchesQuery.data?.isRepo ?? !branchesQuery.isLoading;
56
57 // ── Mutations ─────────────────────────────────────────────────────────54 // ── Mutations ─────────────────────────────────────────────────────────
5855
59 const checkoutMutation = useMutation({56 const checkoutMutation = useMutation({
60 mutationFn: (branch: string) => api!.git.checkout({ cwd: branchCwd!, branch }),57 ...gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),
61 onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
62 onError: (error) => {58 onError: (error) => {
63 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch.");59 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch.");
64 setIsBranchMenuOpen(true);60 setIsBranchMenuOpen(true);
...@@ -66,25 +62,11 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -66,25 +62,11 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
66 });62 });
6763
68 const createBranchMutation = useMutation({64 const createBranchMutation = useMutation({
69 mutationFn: (branch: string) =>65 ...gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),
70 api!.git
71 .createBranch({ cwd: branchCwd!, branch })
72 .then(() => api!.git.checkout({ cwd: branchCwd!, branch })),
73 onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
74 onError: (error) =>66 onError: (error) =>
75 setThreadError(error instanceof Error ? error.message : "Failed to create branch."),67 setThreadError(error instanceof Error ? error.message : "Failed to create branch."),
76 });68 });
7769
78 const initMutation = useMutation({
79 mutationFn: () => api!.git.init({ cwd: branchCwd! }),
80 onSuccess: () => {
81 setThreadError(null);
82 queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] });
83 },
84 onError: (error) =>
85 setThreadError(error instanceof Error ? error.message : "Failed to initialize git repo."),
86 });
87
88 // ── Effects ───────────────────────────────────────────────────────────70 // ── Effects ───────────────────────────────────────────────────────────
8971
90 // Keep thread branch synced to git current branch for local threads.72 // Keep thread branch synced to git current branch for local threads.
...@@ -194,134 +176,118 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B...@@ -194,134 +176,118 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
194 )}176 )}
195 </div>177 </div>
196178
197 {!isRepo ? (179 <Combobox
198 <button180 items={branchNames}
199 type="button"181 autoHighlight
200 className="inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-[12px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-50"182 onOpenChange={(open) => setIsBranchMenuOpen(open)}
201 disabled={!branchCwd || initMutation.isPending}183 open={isBranchMenuOpen}
202 onClick={() => initMutation.mutate()}184 value={activeThread.branch}
203 >185 >
204 {initMutation.isPending ? "Initializing\u2026" : "Initialize git"}186 <ComboboxTrigger
205 </button>187 render={<Button variant="ghost" size="xs" />}
206 ) : (188 className="text-muted-foreground/70 hover:text-foreground/80"
207 <Combobox189 disabled={branchesQuery.isLoading}
208 items={branchNames}
209 autoHighlight
210 onOpenChange={(open) => setIsBranchMenuOpen(open)}
211 open={isBranchMenuOpen}
212 value={activeThread.branch}
213 >190 >
214 <ComboboxTrigger191 <span className="max-w-[240px] truncate">
215 render={<Button variant="ghost" size="xs" />}192 {activeThread.branch
216 className="text-muted-foreground/70 hover:text-foreground/80"193 ? envMode === "worktree" && !activeWorktreePath
217 disabled={branchesQuery.isLoading}194 ? `From ${activeThread.branch}`
218 >195 : activeThread.branch
219 <span className="max-w-[240px] truncate">196 : "Select branch"}
220 {activeThread.branch197 </span>
221 ? envMode === "worktree" && !activeWorktreePath198 <ChevronDownIcon />
222 ? `From ${activeThread.branch}`199 </ComboboxTrigger>
223 : activeThread.branch200 <ComboboxPopup align="end" side="top" className="w-64">
224 : "Select branch"}201 <div className="border-b">
225 </span>202 <ComboboxInput
226 <ChevronDownIcon />203 className="rounded-b-none before:rounded-b-none [&_input]:font-sans"
227 </ComboboxTrigger>204 placeholder="Search branches..."
228 <ComboboxPopup align="end" side="top" className="w-64">205 showClear
229 <div className="border-b">206 showTrigger={false}
230 <ComboboxInput207 size="sm"
231 className="rounded-b-none before:rounded-b-none [&_input]:font-sans"208 />
232 placeholder="Search branches..."209 </div>
233 showClear210 <ComboboxEmpty>No branches found.</ComboboxEmpty>
234 showTrigger={false}
235 size="sm"
236 />
237 </div>
238 <ComboboxEmpty>No branches found.</ComboboxEmpty>
239211
240 <ComboboxList className="max-h-56">212 <ComboboxList className="max-h-56">
241 {(branchName) => {213 {(branchName) => {
242 const branch = branchByName.get(branchName);214 const branch = branchByName.get(branchName);
243 if (!branch) return null;215 if (!branch) return null;
244216
245 const hasSecondaryWorktree =217 const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProject.cwd;
246 branch.worktreePath && branch.worktreePath !== activeProject.cwd;218 return (
247 return (219 <ComboboxItem
248 <ComboboxItem220 hideIndicator
249 hideIndicator221 key={branchName}
250 key={branchName}222 value={branchName}
251 value={branchName}223 className={branchName === activeThread.branch ? "bg-accent text-foreground" : undefined}
252 className={branchName === activeThread.branch ? "bg-accent text-foreground" : undefined}224 onClick={() => selectBranch(branch)}
253 onClick={() => selectBranch(branch)}225 >
254 >226 <div className="flex w-full items-center justify-between gap-2">
255 <div className="flex w-full items-center justify-between gap-2">227 <span className="truncate">{branchName}</span>
256 <span className="truncate">{branchName}</span>228 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (
257 {(branch.current || branch.isDefault || hasSecondaryWorktree) && (229 <span className="shrink-0 text-[10px] text-muted-foreground/45">
258 <span className="shrink-0 text-[10px] text-muted-foreground/45">230 {branch.current ? "current" : hasSecondaryWorktree ? "worktree" : "default"}
259 {branch.current231 </span>
260 ? "current"232 )}
261 : hasSecondaryWorktree233 </div>
262 ? "worktree"234 </ComboboxItem>
263 : "default"}235 );
264 </span>236 }}
265 )}237 </ComboboxList>
266 </div>238 {envMode === "local" && (
267 </ComboboxItem>239 <>
268 );240 {isCreatingBranch ? (
269 }}241 <form
270 </ComboboxList>242 onSubmit={(event) => {
271 {envMode === "local" && (243 event.preventDefault();
272 <>244 createBranch();
273 {isCreatingBranch ? (245 }}
274 <form246 >
275 onSubmit={(event) => {247 <InputGroup className="rounded-t-none before:rounded-t-none">
276 event.preventDefault();248 <InputGroupInput
277 createBranch();249 className="[&_input]:font-sans"
278 }}250 size="sm"
279 >251 placeholder="branch-name"
280 <InputGroup className="rounded-t-none before:rounded-t-none">252 type="text"
281 <InputGroupInput253 value={newBranchName}
282 className="[&_input]:font-sans"254 onChange={(event) => setNewBranchName(event.target.value)}
283 size="sm"255 onKeyDown={(event) => {
284 placeholder="branch-name"256 if (event.key === "Escape") {
285 type="text"257 event.stopPropagation();
286 value={newBranchName}258 setIsCreatingBranch(false);
287 onChange={(event) => setNewBranchName(event.target.value)}259 setNewBranchName("");
288 onKeyDown={(event) => {260 }
289 if (event.key === "Escape") {261 }}
290 event.stopPropagation();262 autoFocus
291 setIsCreatingBranch(false);263 />
292 setNewBranchName("");264 <InputGroupAddon align="inline-end">
293 }265 <Button
294 }}266 size="xs"
295 autoFocus267 variant="secondary"
296 />268 type="submit"
297 <InputGroupAddon align="inline-end">269 disabled={!newBranchName.trim() || createBranchMutation.isPending}
298 <Button270 >
299 size="xs"271 Create
300 variant="secondary"272 </Button>
301 type="submit"273 </InputGroupAddon>
302 disabled={!newBranchName.trim() || createBranchMutation.isPending}274 </InputGroup>
303 >275 </form>
304 Create276 ) : (
305 </Button>277 <Button
306 </InputGroupAddon>278 size="sm"
307 </InputGroup>279 className="w-full justify-start h-9 rounded-t-none rounded-b-lg before:rounded-t-none"
308 </form>280 variant="outline"
309 ) : (281 onClick={() => setIsCreatingBranch(true)}
310 <Button282 >
311 size="sm"283 <PlusIcon />
312 className="w-full justify-start h-9 rounded-t-none rounded-b-lg before:rounded-t-none"284 New branch
313 variant="outline"285 </Button>
314 onClick={() => setIsCreatingBranch(true)}286 )}
315 >287 </>
316 <PlusIcon />288 )}
317 New branch289 </ComboboxPopup>
318 </Button>290 </Combobox>
319 )}
320 </>
321 )}
322 </ComboboxPopup>
323 </Combobox>
324 )}
325 </div>291 </div>
326 );292 );
327}293}
apps/web/src/components/ChatView.tsx+8-3
...@@ -19,6 +19,8 @@ import {...@@ -19,6 +19,8 @@ import {
19 useRef,19 useRef,
20 useState,20 useState,
21} from "react";21} from "react";
22import { useQuery } from "@tanstack/react-query";
23import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";
2224
23import { EDITORS, type EditorId } from "@t3tools/contracts";25import { EDITORS, type EditorId } from "@t3tools/contracts";
24import { isElectron } from "../env";26import { isElectron } from "../env";
...@@ -244,6 +246,9 @@ export default function ChatView() {...@@ -244,6 +246,9 @@ export default function ChatView() {
244 sandboxMode: "workspace-write",246 sandboxMode: "workspace-write",
245 } as const);247 } as const);
246 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;248 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
249 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));
250 // Default true while loading to avoid toolbar flicker.
251 const isGitRepo = branchesQuery.data?.isRepo ?? true;
247252
248 const envLocked = Boolean(253 const envLocked = Boolean(
249 activeThread &&254 activeThread &&
...@@ -1123,7 +1128,7 @@ export default function ChatView() {...@@ -1123,7 +1128,7 @@ export default function ChatView() {
1123 </div>1128 </div>
11241129
1125 {/* Input bar */}1130 {/* Input bar */}
1126 <div className="px-5 pb-1 pt-2">1131 <div className={cn("px-5 pt-2", isGitRepo ? "pb-1" : "pb-4")}>
1127 <form onSubmit={onSend} className="mx-auto max-w-3xl">1132 <form onSubmit={onSend} className="mx-auto max-w-3xl">
1128 <div1133 <div
1129 className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${1134 className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring ${
...@@ -1299,7 +1304,7 @@ export default function ChatView() {...@@ -1299,7 +1304,7 @@ export default function ChatView() {
1299 </form>1304 </form>
1300 </div>1305 </div>
13011306
1302 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />1307 {isGitRepo && <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />}
13031308
1304 {activeThread.terminalOpen && api && activeProject && (1309 {activeThread.terminalOpen && api && activeProject && (
1305 <ThreadTerminalDrawer1310 <ThreadTerminalDrawer
...@@ -1436,8 +1441,8 @@ function OpenInPicker() {...@@ -1436,8 +1441,8 @@ function OpenInPicker() {
14361441
1437 const api = useNativeApi();1442 const api = useNativeApi();
1438 const { state } = useStore();1443 const { state } = useStore();
1439 const activeProject = state.projects.find((p) => p.id === state.activeThreadId);
1440 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);1444 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
1445 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
14411446
1442 const openInEditor = useCallback(1447 const openInEditor = useCallback(
1443 (editorId: EditorId | null) => {1448 (editorId: EditorId | null) => {
apps/web/src/components/GitActionsControl.logic.test.ts+73-18
...@@ -10,6 +10,11 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {...@@ -10,6 +10,11 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
10 return {10 return {
11 branch: "feature/test",11 branch: "feature/test",
12 hasWorkingTreeChanges: false,12 hasWorkingTreeChanges: false,
13 workingTree: {
14 files: [],
15 insertions: 0,
16 deletions: 0,
17 },
13 hasUpstream: true,18 hasUpstream: true,
14 aheadCount: 0,19 aheadCount: 0,
15 behindCount: 0,20 behindCount: 0,
...@@ -81,7 +86,7 @@ describe("when: actions are busy", () => {...@@ -81,7 +86,7 @@ describe("when: actions are busy", () => {
81 const quick = resolveQuickAction(status(), true);86 const quick = resolveQuickAction(status(), true);
82 assert.deepInclude(quick, {87 assert.deepInclude(quick, {
83 kind: "show_hint",88 kind: "show_hint",
84 label: "Running...",89 label: "Commit",
85 disabled: true,90 disabled: true,
86 hint: "Git action in progress.",91 hint: "Git action in progress.",
87 });92 });
...@@ -123,7 +128,7 @@ describe("when: git status is unavailable", () => {...@@ -123,7 +128,7 @@ describe("when: git status is unavailable", () => {
123 const quick = resolveQuickAction(null, false);128 const quick = resolveQuickAction(null, false);
124 assert.deepInclude(quick, {129 assert.deepInclude(quick, {
125 kind: "show_hint",130 kind: "show_hint",
126 label: "Git actions",131 label: "Commit",
127 disabled: true,132 disabled: true,
128 hint: "Git status is unavailable.",133 hint: "Git status is unavailable.",
129 });134 });
...@@ -314,9 +319,34 @@ describe("when: branch is behind upstream", () => {...@@ -314,9 +319,34 @@ describe("when: branch is behind upstream", () => {
314});319});
315320
316describe("when: working tree has local changes", () => {321describe("when: working tree has local changes", () => {
317 it("resolveQuickAction returns commit", () => {322 it("resolveQuickAction returns commit, push, and create PR", () => {
318 const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false);323 const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false);
319 assert.deepInclude(quick, { kind: "run_action", action: "commit", label: "Commit" });324 assert.deepInclude(quick, {
325 kind: "run_action",
326 action: "commit_push_pr",
327 label: "Commit, push & create PR",
328 });
329 });
330
331 it("resolveQuickAction returns commit and push when open PR exists", () => {
332 const quick = resolveQuickAction(
333 status({
334 hasWorkingTreeChanges: true,
335 openPr: {
336 number: 16,
337 title: "Existing PR",
338 url: "https://example.com/pr/16",
339 baseBranch: "main",
340 headBranch: "feature/test",
341 },
342 }),
343 false,
344 );
345 assert.deepInclude(quick, {
346 kind: "run_action",
347 action: "commit_push",
348 label: "Commit & push",
349 });
320 });350 });
321351
322 it("buildMenuItems enables commit and disables push and PR", () => {352 it("buildMenuItems enables commit and disables push and PR", () => {
...@@ -350,10 +380,36 @@ describe("when: working tree has local changes", () => {...@@ -350,10 +380,36 @@ describe("when: working tree has local changes", () => {
350 });380 });
351});381});
352382
383describe("when: on default branch without open PR", () => {
384 it("resolveQuickAction returns commit and push when local changes exist", () => {
385 const quick = resolveQuickAction(status({ branch: "main", hasWorkingTreeChanges: true }), false, true);
386 assert.deepInclude(quick, {
387 kind: "run_action",
388 action: "commit_push",
389 label: "Commit & push",
390 disabled: false,
391 });
392 });
393
394 it("resolveQuickAction returns push when branch is ahead", () => {
395 const quick = resolveQuickAction(status({ branch: "main", aheadCount: 2, openPr: null }), false, true);
396 assert.deepInclude(quick, {
397 kind: "run_action",
398 action: "commit_push",
399 label: "Push",
400 disabled: false,
401 });
402 });
403});
404
353describe("when: working tree has local changes and branch is behind upstream", () => {405describe("when: working tree has local changes and branch is behind upstream", () => {
354 it("resolveQuickAction still prefers commit", () => {406 it("resolveQuickAction still prefers commit, push, and create PR", () => {
355 const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true, behindCount: 1 }), false);407 const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true, behindCount: 1 }), false);
356 assert.deepInclude(quick, { kind: "run_action", action: "commit", label: "Commit" });408 assert.deepInclude(quick, {
409 kind: "run_action",
410 action: "commit_push_pr",
411 label: "Commit, push & create PR",
412 });
357 });413 });
358414
359 it("buildMenuItems enables commit and keeps push and PR disabled", () => {415 it("buildMenuItems enables commit and keeps push and PR disabled", () => {
...@@ -393,7 +449,7 @@ describe("when: HEAD is detached and there are no local changes", () => {...@@ -393,7 +449,7 @@ describe("when: HEAD is detached and there are no local changes", () => {
393 status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }),449 status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }),
394 false,450 false,
395 );451 );
396 assert.deepInclude(quick, { kind: "show_hint", label: "Detached HEAD", disabled: false });452 assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true });
397 });453 });
398454
399 it("buildMenuItems keeps commit, push, and PR disabled", () => {455 it("buildMenuItems keeps commit, push, and PR disabled", () => {
...@@ -428,17 +484,16 @@ describe("when: HEAD is detached and there are no local changes", () => {...@@ -428,17 +484,16 @@ describe("when: HEAD is detached and there are no local changes", () => {
428});484});
429485
430describe("when: branch has no upstream configured", () => {486describe("when: branch has no upstream configured", () => {
431 it("resolveQuickAction runs push and create PR when clean and no open PR", () => {487 it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => {
432 const quick = resolveQuickAction(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false);488 const quick = resolveQuickAction(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false);
433 assert.deepInclude(quick, {489 assert.deepInclude(quick, {
434 kind: "run_action",490 kind: "show_hint",
435 action: "commit_push_pr",491 label: "Commit",
436 label: "Push & create PR",492 disabled: true,
437 disabled: false,
438 });493 });
439 });494 });
440495
441 it("resolveQuickAction opens PR when clean, no upstream, and no local commits ahead", () => {496 it("resolveQuickAction remains disabled when clean, no upstream, and no local commits ahead", () => {
442 const quick = resolveQuickAction(497 const quick = resolveQuickAction(
443 status({498 status({
444 hasUpstream: false,499 hasUpstream: false,
...@@ -454,9 +509,9 @@ describe("when: branch has no upstream configured", () => {...@@ -454,9 +509,9 @@ describe("when: branch has no upstream configured", () => {
454 false,509 false,
455 );510 );
456 assert.deepInclude(quick, {511 assert.deepInclude(quick, {
457 kind: "open_pr",512 kind: "show_hint",
458 label: "Open PR",513 label: "Commit",
459 disabled: false,514 disabled: true,
460 });515 });
461 });516 });
462517
...@@ -483,7 +538,7 @@ describe("when: branch has no upstream configured", () => {...@@ -483,7 +538,7 @@ describe("when: branch has no upstream configured", () => {
483 });538 });
484 });539 });
485540
486 it("buildMenuItems enables push and disables create PR", () => {541 it("buildMenuItems disables push and create PR when no commits are ahead", () => {
487 const items = buildMenuItems(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false);542 const items = buildMenuItems(status({ hasUpstream: false, openPr: null, aheadCount: 0 }), false);
488 assert.deepEqual(items, [543 assert.deepEqual(items, [
489 {544 {
...@@ -497,7 +552,7 @@ describe("when: branch has no upstream configured", () => {...@@ -497,7 +552,7 @@ describe("when: branch has no upstream configured", () => {
497 {552 {
498 id: "push",553 id: "push",
499 label: "Push",554 label: "Push",
500 disabled: false,555 disabled: true,
501 icon: "push",556 icon: "push",
502 kind: "open_dialog",557 kind: "open_dialog",
503 dialogAction: "push",558 dialogAction: "push",
apps/web/src/components/GitActionsControl.logic.ts+34-36
...@@ -61,7 +61,7 @@ export function buildMenuItems(...@@ -61,7 +61,7 @@ export function buildMenuItems(
61 hasBranch &&61 hasBranch &&
62 !hasChanges &&62 !hasChanges &&
63 !isBehind &&63 !isBehind &&
64 (gitStatus.aheadCount > 0 || !gitStatus.hasUpstream);64 gitStatus.aheadCount > 0;
65 const canCreatePr =65 const canCreatePr =
66 !isBusy &&66 !isBusy &&
67 hasBranch &&67 hasBranch &&
...@@ -111,14 +111,15 @@ export function buildMenuItems(...@@ -111,14 +111,15 @@ export function buildMenuItems(
111export function resolveQuickAction(111export function resolveQuickAction(
112 gitStatus: GitStatusResult | null,112 gitStatus: GitStatusResult | null,
113 isBusy: boolean,113 isBusy: boolean,
114 isDefaultBranch = false,
114): GitQuickAction {115): GitQuickAction {
115 if (isBusy) {116 if (isBusy) {
116 return { label: "Running...", disabled: true, kind: "show_hint", hint: "Git action in progress." };117 return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." };
117 }118 }
118119
119 if (!gitStatus) {120 if (!gitStatus) {
120 return {121 return {
121 label: "Git actions",122 label: "Commit",
122 disabled: true,123 disabled: true,
123 kind: "show_hint",124 kind: "show_hint",
124 hint: "Git status is unavailable.",125 hint: "Git status is unavailable.",
...@@ -128,14 +129,31 @@ export function resolveQuickAction(...@@ -128,14 +129,31 @@ export function resolveQuickAction(
128 const hasBranch = gitStatus.branch !== null;129 const hasBranch = gitStatus.branch !== null;
129 const hasChanges = gitStatus.hasWorkingTreeChanges;130 const hasChanges = gitStatus.hasWorkingTreeChanges;
130 const hasOpenPr = gitStatus.openPr !== null;131 const hasOpenPr = gitStatus.openPr !== null;
132 const isAhead = gitStatus.aheadCount > 0;
131 const isBehind = gitStatus.behindCount > 0;133 const isBehind = gitStatus.behindCount > 0;
132 const canPush = hasBranch && !hasChanges && !isBehind;
133134
134 if (!hasChanges && hasOpenPr && gitStatus.aheadCount === 0 && !isBehind) {135 if (!hasBranch) {
135 return { label: "Open PR", disabled: false, kind: "open_pr" };136 return {
137 label: "Commit",
138 disabled: true,
139 kind: "show_hint",
140 hint: "Create and checkout a branch before pushing or opening a PR.",
141 };
136 }142 }
137143
138 if (!hasChanges && isBehind) {144 if (hasChanges) {
145 if (hasOpenPr || isDefaultBranch) {
146 return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" };
147 }
148 return {
149 label: "Commit, push & create PR",
150 disabled: false,
151 kind: "run_action",
152 action: "commit_push_pr",
153 };
154 }
155
156 if (isBehind) {
139 return {157 return {
140 label: "Pull",158 label: "Pull",
141 disabled: false,159 disabled: false,
...@@ -143,8 +161,8 @@ export function resolveQuickAction(...@@ -143,8 +161,8 @@ export function resolveQuickAction(
143 };161 };
144 }162 }
145163
146 if (!hasChanges && canPush && (gitStatus.aheadCount > 0 || !gitStatus.hasUpstream)) {164 if (isAhead) {
147 if (hasOpenPr) {165 if (hasOpenPr || isDefaultBranch) {
148 return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" };166 return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" };
149 }167 }
150 return {168 return {
...@@ -155,36 +173,16 @@ export function resolveQuickAction(...@@ -155,36 +173,16 @@ export function resolveQuickAction(
155 };173 };
156 }174 }
157175
158 if (hasChanges) {176 if (hasOpenPr && gitStatus.hasUpstream) {
159 return { label: "Commit", disabled: false, kind: "run_action", action: "commit" };
160 }
161
162 if (hasOpenPr) {
163 return { label: "Open PR", disabled: false, kind: "open_pr" };177 return { label: "Open PR", disabled: false, kind: "open_pr" };
164 }178 }
165179
166 if (hasBranch && gitStatus.hasUpstream && !isBehind) {180 return {
167 if (gitStatus.aheadCount > 0) {181 label: "Commit",
168 return { label: "Create PR", disabled: false, kind: "run_action", action: "commit_push_pr" };182 disabled: true,
169 }183 kind: "show_hint",
170 return {184 hint: "Branch is up to date. Nothing to commit, push, pull, or open as a PR.",
171 label: "Commit",185 };
172 disabled: true,
173 kind: "show_hint",
174 hint: "Branch is up to date. Nothing to commit, push, pull, or open as a PR.",
175 };
176 }
177
178 if (!hasBranch) {
179 return {
180 label: "Detached HEAD",
181 disabled: false,
182 kind: "show_hint",
183 hint: "Create and checkout a branch before pushing or opening a PR.",
184 };
185 }
186
187 return { label: "Commit", disabled: true, kind: "show_hint", hint: "No git action needed." };
188}186}
189187
190export function requiresDefaultBranchConfirmation(188export function requiresDefaultBranchConfirmation(
apps/web/src/components/GitActionsControl.tsx+386-138
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import {1import {
2 type GitStatusResult,
2 type GitStackedAction,3 type GitStackedAction,
3 type NativeApi,4 type NativeApi,
4} from "@t3tools/contracts";5} from "@t3tools/contracts";
5import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";6import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6import { useCallback, useMemo, useState } from "react";7import { useCallback, useMemo, useState } from "react";
7import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";8import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
8import { GitHubIcon } from "./Icons";9import { GitHubIcon } from "./Icons";
...@@ -16,6 +17,7 @@ import {...@@ -16,6 +17,7 @@ import {
16 requiresDefaultBranchConfirmation,17 requiresDefaultBranchConfirmation,
17 resolveQuickAction,18 resolveQuickAction,
18} from "./GitActionsControl.logic";19} from "./GitActionsControl.logic";
20import { Badge } from "~/components/ui/badge";
19import { Button } from "~/components/ui/button";21import { Button } from "~/components/ui/button";
20import {22import {
21 Dialog,23 Dialog,
...@@ -28,14 +30,97 @@ import {...@@ -28,14 +30,97 @@ import {
28} from "~/components/ui/dialog";30} from "~/components/ui/dialog";
29import { Group, GroupSeparator } from "~/components/ui/group";31import { Group, GroupSeparator } from "~/components/ui/group";
30import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";32import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
33import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
31import { Textarea } from "~/components/ui/textarea";34import { Textarea } from "~/components/ui/textarea";
32import { toastManager } from "~/components/ui/toast";35import { toastManager } from "~/components/ui/toast";
36import {
37 gitBranchesQueryOptions,
38 gitInitMutationOptions,
39 gitPullMutationOptions,
40 gitRunStackedActionMutationOptions,
41 gitStatusQueryOptions,
42 invalidateGitQueries,
43} from "~/lib/gitReactQuery";
44import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";
3345
34interface GitActionsControlProps {46interface GitActionsControlProps {
35 api: NativeApi | undefined;47 api: NativeApi | undefined;
36 gitCwd: string | null;48 gitCwd: string | null;
37}49}
3850
51function getMenuActionDisabledReason(
52 item: GitActionMenuItem,
53 gitStatus: GitStatusResult | null,
54 isBusy: boolean,
55): string | null {
56 if (!item.disabled) return null;
57 if (isBusy) return "Git action in progress.";
58 if (!gitStatus) return "Git status is unavailable.";
59
60 const hasBranch = gitStatus.branch !== null;
61 const hasChanges = gitStatus.hasWorkingTreeChanges;
62 const hasOpenPr = gitStatus.openPr !== null;
63 const isAhead = gitStatus.aheadCount > 0;
64 const isBehind = gitStatus.behindCount > 0;
65
66 if (item.id === "commit") {
67 if (!hasChanges) {
68 return "Worktree is clean. Make changes before committing.";
69 }
70 return "Commit is currently unavailable.";
71 }
72
73 if (item.id === "push") {
74 if (!hasBranch) {
75 return "Detached HEAD: checkout a branch before pushing.";
76 }
77 if (hasChanges) {
78 return "Commit or stash local changes before pushing.";
79 }
80 if (isBehind) {
81 return "Branch is behind upstream. Pull/rebase before pushing.";
82 }
83 if (!isAhead) {
84 return "No local commits to push.";
85 }
86 return "Push is currently unavailable.";
87 }
88
89 if (hasOpenPr) {
90 return "Open PR is currently unavailable.";
91 }
92 if (!hasBranch) {
93 return "Detached HEAD: checkout a branch before creating a PR.";
94 }
95 if (hasChanges) {
96 return "Commit local changes before creating a PR.";
97 }
98 if (!isAhead) {
99 return "No local commits to include in a PR.";
100 }
101 if (!gitStatus.hasUpstream) {
102 return "Set an upstream branch before creating a PR.";
103 }
104 if (isBehind) {
105 return "Branch is behind upstream. Pull/rebase before creating a PR.";
106 }
107 return "Create PR is currently unavailable.";
108}
109
110function formatUpstreamStatus(input: {
111 hasUpstream: boolean;
112 aheadCount: number;
113 behindCount: number;
114}): string {
115 if (!input.hasUpstream) return "No upstream configured";
116 if (input.aheadCount === 0 && input.behindCount === 0) return "Up to date";
117 if (input.aheadCount > 0 && input.behindCount > 0) {
118 return `Diverged (+${input.aheadCount} / -${input.behindCount})`;
119 }
120 if (input.aheadCount > 0) return `Ahead by ${input.aheadCount}`;
121 return `Behind by ${input.behindCount}`;
122}
123
39function GitActionItemIcon({ icon }: { icon: GitActionIconName }) {124function GitActionItemIcon({ icon }: { icon: GitActionIconName }) {
40 if (icon === "commit") return <GitCommitIcon />;125 if (icon === "commit") return <GitCommitIcon />;
41 if (icon === "push") return <CloudUploadIcon />;126 if (icon === "push") return <CloudUploadIcon />;
...@@ -54,21 +139,20 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {...@@ -54,21 +139,20 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
54 return <InfoIcon className="size-4" />;139 return <InfoIcon className="size-4" />;
55}140}
56141
57function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] {142function buildGitActionProgressStages(input: {
58 return ["git", "status", gitCwd];143 action: GitStackedAction;
59}144 hasCustomCommitMessage: boolean;
60145}): string[] {
61function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null) {146 const commitStages = input.hasCustomCommitMessage
62 return queryOptions({147 ? ["Committing..."]
63 queryKey: getGitStatusQueryKey(gitCwd),148 : ["Generating commit message...", "Committing..."];
64 queryFn: async () => {149 if (input.action === "commit") {
65 if (!api || !gitCwd) {150 return commitStages;
66 throw new Error("Git status is unavailable.");151 }
67 }152 if (input.action === "commit_push") {
68 return api.git.status({ cwd: gitCwd });153 return [...commitStages, "Pushing..."];
69 },154 }
70 enabled: !!api && !!gitCwd,155 return [...commitStages, "Pushing...", "Creating PR..."];
71 });
72}156}
73157
74export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {158export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {
...@@ -80,41 +164,16 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -80,41 +164,16 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
80 gitStatusQueryOptions(api, gitCwd),164 gitStatusQueryOptions(api, gitCwd),
81 );165 );
82166
83 const { data: branchList = null } = useQuery({167 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd));
84 queryKey: ["git", "branches", gitCwd],168 // Default to true while loading so we don't flash init controls.
85 queryFn: async () => {169 const isRepo = branchList?.isRepo ?? true;
86 if (!api || !gitCwd) throw new Error("Git branches are unavailable.");
87 return api.git.listBranches({ cwd: gitCwd });
88 },
89 enabled: !!api && !!gitCwd,
90 });
91170
92 const runImmediateGitActionMutation = useMutation({171 const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient }));
93 mutationFn: async ({172
94 action,173 const runImmediateGitActionMutation = useMutation(
95 commitMessage,174 gitRunStackedActionMutationOptions({ api, cwd: gitCwd, queryClient }),
96 }: {175 );
97 action: GitStackedAction;176 const pullMutation = useMutation(gitPullMutationOptions({ api, cwd: gitCwd, queryClient }));
98 commitMessage?: string;
99 }) => {
100 if (!api || !gitCwd) {
101 throw new Error("Git action is unavailable.");
102 }
103 return api.git.runStackedAction({ cwd: gitCwd, action, ...(commitMessage ? { commitMessage } : {}) });
104 },
105 onSuccess: async () => {
106 await queryClient.invalidateQueries({ queryKey: ["git"] });
107 },
108 });
109 const pullMutation = useMutation({
110 mutationFn: async () => {
111 if (!api || !gitCwd) throw new Error("Git pull is unavailable.");
112 return api.git.pull({ cwd: gitCwd });
113 },
114 onSuccess: async () => {
115 await queryClient.invalidateQueries({ queryKey: ["git"] });
116 },
117 });
118177
119 const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending;178 const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending;
120 const isDefaultBranch = useMemo(() => {179 const isDefaultBranch = useMemo(() => {
...@@ -129,13 +188,20 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -129,13 +188,20 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
129 [gitStatus, isGitActionRunning],188 [gitStatus, isGitActionRunning],
130 );189 );
131 const quickAction = useMemo(190 const quickAction = useMemo(
132 () => resolveQuickAction(gitStatus, isGitActionRunning),191 () => resolveQuickAction(gitStatus, isGitActionRunning, isDefaultBranch),
133 [gitStatus, isGitActionRunning],192 [gitStatus, isDefaultBranch, isGitActionRunning],
193 );
194 const quickActionDisabledReason = quickAction.disabled
195 ? (quickAction.hint ?? "This action is currently unavailable.")
196 : null;
197 const [isQuickActionPopoverOpen, setIsQuickActionPopoverOpen] = useState(false);
198 const [openDisabledMenuItemId, setOpenDisabledMenuItemId] = useState<GitActionMenuItem["id"] | null>(
199 null,
134 );200 );
135201
136 const refreshGitStatus = useCallback(async () => {202 const refreshGitStatus = useCallback(async () => {
137 if (!api || !gitCwd) return;203 if (!api || !gitCwd) return;
138 await queryClient.invalidateQueries({ queryKey: ["git"] });204 await invalidateGitQueries(queryClient);
139 }, [api, gitCwd, queryClient]);205 }, [api, gitCwd, queryClient]);
140206
141 const maybeConfirmPushToDefaultBranch = useCallback(207 const maybeConfirmPushToDefaultBranch = useCallback(
...@@ -184,31 +250,60 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -184,31 +250,60 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
184 const confirmed = await maybeConfirmPushToDefaultBranch(action);250 const confirmed = await maybeConfirmPushToDefaultBranch(action);
185 if (!confirmed) return;251 if (!confirmed) return;
186252
253 const progressStages = buildGitActionProgressStages({
254 action,
255 hasCustomCommitMessage: !!commitMessage?.trim(),
256 });
257 const progressToastId = toastManager.add({
258 type: "loading",
259 title: progressStages[0] ?? "Running git action...",
260 timeout: 0,
261 });
262
263 let stageIndex = 0;
264 const stageInterval = setInterval(() => {
265 stageIndex = Math.min(stageIndex + 1, progressStages.length - 1);
266 toastManager.update(progressToastId, {
267 title: progressStages[stageIndex] ?? "Running git action...",
268 type: "loading",
269 timeout: 0,
270 });
271 }, 1100);
272
273 const stopProgressUpdates = () => {
274 clearInterval(stageInterval);
275 };
276
187 const promise = runImmediateGitActionMutation.mutateAsync({277 const promise = runImmediateGitActionMutation.mutateAsync({
188 action,278 action,
189 ...(commitMessage ? { commitMessage } : {}),279 ...(commitMessage ? { commitMessage } : {}),
190 });280 });
191 toastManager.promise(promise, {281
192 loading: {282 try {
193 title:283 const result = await promise;
194 action === "commit"284 stopProgressUpdates();
195 ? "Committing..."285
196 : action === "commit_push"286 const prUrl = result.pr.url ?? gitStatus?.openPr?.url;
197 ? "Pushing..."287 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";
198 : "Creating PR...",288 const shouldOfferOpenPrCta =
199 },289 (action === "commit_push" || action === "commit_push_pr") && !!prUrl;
200 success: (result) => {290 const shouldOfferCreatePrCta =
201 const prUrl = result.pr.url ?? gitStatus?.openPr?.url;291 action === "commit_push" && !prUrl && result.push.status === "pushed";
202 const shouldOfferCreatePrCta =292
203 action === "commit_push" &&293 toastManager.update(progressToastId, {
204 !prUrl &&294 type: "success",
205 result.push.status === "pushed";295 title: "Done",
206 return {296 description: describeGitResult(result),
207 title: "Done",297 timeout: 10_000,
208 description: describeGitResult(result),298 ...(shouldOfferPushCta
209 ...(prUrl299 ? {
300 actionProps: {
301 children: "Push",
302 onClick: () => setActiveDialogAction("push"),
303 },
304 }
305 : shouldOfferOpenPrCta
210 ? {306 ? {
211 timeout: 10_000,
212 actionProps: {307 actionProps: {
213 children: "Open PR",308 children: "Open PR",
214 onClick: () => void api?.shell.openExternal(prUrl),309 onClick: () => void api?.shell.openExternal(prUrl),
...@@ -216,21 +311,21 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -216,21 +311,21 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
216 }311 }
217 : shouldOfferCreatePrCta312 : shouldOfferCreatePrCta
218 ? {313 ? {
219 timeout: 10_000,
220 actionProps: {314 actionProps: {
221 children: "Create PR",315 children: "Create PR",
222 onClick: () => setActiveDialogAction("create_pr"),316 onClick: () => setActiveDialogAction("create_pr"),
223 },317 },
224 }318 }
225 : {}),319 : {}),
226 };320 });
227 },321 } catch (err) {
228 error: (err) => ({322 stopProgressUpdates();
323 toastManager.update(progressToastId, {
324 type: "error",
229 title: "Action failed",325 title: "Action failed",
230 description: err instanceof Error ? err.message : "An error occurred.",326 description: err instanceof Error ? err.message : "An error occurred.",
231 }),327 });
232 });328 }
233 void promise.catch(() => undefined);
234 },329 },
235 [330 [
236 api,331 api,
...@@ -309,6 +404,27 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -309,6 +404,27 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
309 });404 });
310 }, [activeDialogAction, dialogCommitMessage, runGitActionWithToast]);405 }, [activeDialogAction, dialogCommitMessage, runGitActionWithToast]);
311406
407 const openChangedFileInEditor = useCallback(
408 (filePath: string) => {
409 if (!api || !gitCwd) {
410 toastManager.add({
411 type: "error",
412 title: "Editor opening is unavailable.",
413 });
414 return;
415 }
416 const target = resolvePathLinkTarget(filePath, gitCwd);
417 void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => {
418 toastManager.add({
419 type: "error",
420 title: "Unable to open file",
421 description: error instanceof Error ? error.message : "An error occurred.",
422 });
423 });
424 },
425 [api, gitCwd],
426 );
427
312 const dialogTitle =428 const dialogTitle =
313 activeDialogAction === "commit"429 activeDialogAction === "commit"
314 ? "Commit changes"430 ? "Commit changes"
...@@ -321,65 +437,144 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -321,65 +437,144 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
321 : activeDialogAction === "push"437 : activeDialogAction === "push"
322 ? "Push this branch now. If this is the default branch, you'll be asked to confirm."438 ? "Push this branch now. If this is the default branch, you'll be asked to confirm."
323 : "Create a pull request using generated title/body content.";439 : "Create a pull request using generated title/body content.";
440 const showCompactStatusCard =
441 !!gitStatus && gitStatus.hasWorkingTreeChanges && !gitStatus.hasUpstream;
324442
325 if (!gitCwd) return null;443 if (!gitCwd) return null;
326444
327 return (445 return (
328 <>446 <>
329 <Group aria-label="Git actions">447 {!isRepo ? (
330 <Button448 <Button
331 variant="outline"449 variant="outline"
332 size="xs"450 size="xs"
333 disabled={isGitActionRunning || quickAction.disabled}451 disabled={initMutation.isPending}
334 onClick={runQuickAction}452 onClick={() => initMutation.mutate()}
335 >453 >
336 <GitQuickActionIcon quickAction={quickAction} />454 {initMutation.isPending ? "Initializing..." : "Initialize Git"}
337 {quickAction.label}
338 </Button>455 </Button>
339 <GroupSeparator />456 ) : (
340 <Menu457 <Group aria-label="Git actions">
341 onOpenChange={(open) => {458 {quickActionDisabledReason ? (
342 if (open) void refreshGitStatus();459 <Popover open={isQuickActionPopoverOpen} onOpenChange={setIsQuickActionPopoverOpen}>
343 }}460 <PopoverTrigger
344 >461 render={
345 <MenuTrigger462 <span
346 render={<Button aria-label="Git action options" size="icon-xs" variant="outline" />}463 className="inline-flex cursor-not-allowed"
347 disabled={isGitActionRunning}464 onBlur={() => setIsQuickActionPopoverOpen(false)}
348 >465 onFocus={() => setIsQuickActionPopoverOpen(true)}
349 <ChevronDownIcon aria-hidden="true" className="size-4" />466 onMouseEnter={() => setIsQuickActionPopoverOpen(true)}
350 </MenuTrigger>467 onMouseLeave={() => setIsQuickActionPopoverOpen(false)}
351 <MenuPopup align="end" sideOffset={4}>468 tabIndex={0}
352 {gitActionMenuItems.map((item) => (469 />
353 <MenuItem470 }
354 key={`${item.id}-${item.label}`}
355 disabled={item.disabled}
356 onClick={() => {
357 openDialogForMenuItem(item);
358 }}
359 >471 >
360 <GitActionItemIcon icon={item.icon} />472 <Button
361 {item.label}473 variant="outline"
362 </MenuItem>474 size="xs"
363 ))}475 disabled={isGitActionRunning || quickAction.disabled}
364 {gitStatus?.branch === null && (476 className="cursor-not-allowed disabled:cursor-not-allowed"
365 <p className="px-2 py-1.5 text-xs text-warning">477 onClick={runQuickAction}
366 Detached HEAD: create and checkout a branch to enable push and PR actions.478 >
367 </p>479 <GitQuickActionIcon quickAction={quickAction} />
368 )}480 {quickAction.label}
369 {gitStatus &&481 </Button>
370 gitStatus.branch !== null &&482 </PopoverTrigger>
371 !gitStatus.hasWorkingTreeChanges &&483 <PopoverPopup tooltipStyle side="bottom" align="start">
372 gitStatus.behindCount > 0 && (484 {quickActionDisabledReason}
485 </PopoverPopup>
486 </Popover>
487 ) : (
488 <Button
489 variant="outline"
490 size="xs"
491 disabled={isGitActionRunning || quickAction.disabled}
492 onClick={runQuickAction}
493 >
494 <GitQuickActionIcon quickAction={quickAction} />
495 {quickAction.label}
496 </Button>
497 )}
498 <GroupSeparator />
499 <Menu
500 onOpenChange={(open) => {
501 if (open) void refreshGitStatus();
502 if (!open) setOpenDisabledMenuItemId(null);
503 }}
504 >
505 <MenuTrigger
506 render={<Button aria-label="Git action options" size="icon-xs" variant="outline" />}
507 disabled={isGitActionRunning}
508 >
509 <ChevronDownIcon aria-hidden="true" className="size-4" />
510 </MenuTrigger>
511 <MenuPopup align="end" sideOffset={4}>
512 {gitActionMenuItems.map((item) => {
513 const disabledReason = getMenuActionDisabledReason(item, gitStatus, isGitActionRunning);
514 if (item.disabled && disabledReason) {
515 return (
516 <Popover
517 key={`${item.id}-${item.label}`}
518 open={openDisabledMenuItemId === item.id}
519 onOpenChange={(open) => setOpenDisabledMenuItemId(open ? item.id : null)}
520 >
521 <PopoverTrigger
522 render={
523 <span
524 className="inline-flex w-full cursor-not-allowed"
525 onBlur={() => setOpenDisabledMenuItemId(null)}
526 onFocus={() => setOpenDisabledMenuItemId(item.id)}
527 onMouseEnter={() => setOpenDisabledMenuItemId(item.id)}
528 onMouseLeave={() => setOpenDisabledMenuItemId(null)}
529 tabIndex={0}
530 />
531 }
532 >
533 <MenuItem disabled>
534 <GitActionItemIcon icon={item.icon} />
535 {item.label}
536 </MenuItem>
537 </PopoverTrigger>
538 <PopoverPopup tooltipStyle side="left" align="center">
539 {disabledReason}
540 </PopoverPopup>
541 </Popover>
542 );
543 }
544
545 return (
546 <MenuItem
547 key={`${item.id}-${item.label}`}
548 disabled={item.disabled}
549 onClick={() => {
550 openDialogForMenuItem(item);
551 }}
552 >
553 <GitActionItemIcon icon={item.icon} />
554 {item.label}
555 </MenuItem>
556 );
557 })}
558 {gitStatus?.branch === null && (
373 <p className="px-2 py-1.5 text-xs text-warning">559 <p className="px-2 py-1.5 text-xs text-warning">
374 Behind upstream. Pull/rebase first.560 Detached HEAD: create and checkout a branch to enable push and PR actions.
375 </p>561 </p>
376 )}562 )}
377 {gitStatusError && (563 {gitStatus &&
378 <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p>564 gitStatus.branch !== null &&
379 )}565 !gitStatus.hasWorkingTreeChanges &&
380 </MenuPopup>566 gitStatus.behindCount > 0 && (
381 </Menu>567 <p className="px-2 py-1.5 text-xs text-warning">
382 </Group>568 Behind upstream. Pull/rebase first.
569 </p>
570 )}
571 {gitStatusError && (
572 <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError.message}</p>
573 )}
574 </MenuPopup>
575 </Menu>
576 </Group>
577 )}
383578
384 <Dialog579 <Dialog
385 open={activeDialogAction !== null}580 open={activeDialogAction !== null}
...@@ -396,19 +591,72 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp...@@ -396,19 +591,72 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
396 <DialogDescription>{dialogDescription}</DialogDescription>591 <DialogDescription>{dialogDescription}</DialogDescription>
397 </DialogHeader>592 </DialogHeader>
398 <DialogPanel className="space-y-4">593 <DialogPanel className="space-y-4">
399 <div className="rounded-lg border border-input bg-muted/40 p-3 text-xs">594 <div className="space-y-3 rounded-lg border border-input bg-muted/40 p-3 text-xs">
400 <p>595 <div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1">
401 <span className="font-medium">Branch:</span>{" "}596 <span className="text-muted-foreground">Branch</span>
402 {gitStatus?.branch ?? "(detached HEAD)"}597 <span className="font-medium">{gitStatus?.branch ?? "(detached HEAD)"}</span>
403 </p>598 {!showCompactStatusCard && (
404 <p>599 <>
405 <span className="font-medium">Working tree:</span>{" "}600 <span className="text-muted-foreground">Upstream</span>
406 {gitStatus?.hasWorkingTreeChanges ? "Has changes" : "Clean"}601 <span className="font-medium">
407 </p>602 {gitStatus
408 <p>603 ? formatUpstreamStatus({
409 <span className="font-medium">Ahead/behind:</span>{" "}604 hasUpstream: gitStatus.hasUpstream,
410 {gitStatus ? `${gitStatus.aheadCount}/${gitStatus.behindCount}` : "unknown"}605 aheadCount: gitStatus.aheadCount,
411 </p>606 behindCount: gitStatus.behindCount,
607 })
608 : "unknown"}
609 </span>
610 <span className="text-muted-foreground">Working tree</span>
611 <span className="font-medium">
612 {!gitStatus || !gitStatus.hasWorkingTreeChanges
613 ? "Clean"
614 : `${gitStatus.workingTree.files.length} file(s)`}
615 </span>
616 <span className="text-muted-foreground">Diff</span>
617 <span className="font-mono">
618 {!gitStatus || !gitStatus.hasWorkingTreeChanges ? (
619 "none"
620 ) : (
621 <>
622 <span className="text-success">+{gitStatus.workingTree.insertions}</span>
623 <span className="text-muted-foreground"> / </span>
624 <span className="text-destructive">-{gitStatus.workingTree.deletions}</span>
625 </>
626 )}
627 </span>
628 </>
629 )}
630 </div>
631 <div className="space-y-1">
632 <p className="text-muted-foreground">Files</p>
633 {!gitStatus || gitStatus.workingTree.files.length === 0 ? (
634 <p className="font-medium">none</p>
635 ) : (
636 <div className="space-y-1">
637 {gitStatus.workingTree.files.slice(0, 6).map((file) => (
638 <button
639 type="button"
640 key={file.path}
641 className="flex w-full items-center justify-between gap-3 rounded-md border border-input bg-background px-2 py-1 font-mono text-left transition-colors hover:bg-accent/50"
642 onClick={() => openChangedFileInEditor(file.path)}
643 >
644 <span className="truncate">{file.path}</span>
645 <span className="shrink-0">
646 <span className="text-success">+{file.insertions}</span>
647 <span className="text-muted-foreground"> / </span>
648 <span className="text-destructive">-{file.deletions}</span>
649 </span>
650 </button>
651 ))}
652 {gitStatus.workingTree.files.length > 6 && (
653 <Badge variant="outline" size="sm">
654 +{gitStatus.workingTree.files.length - 6} more
655 </Badge>
656 )}
657 </div>
658 )}
659 </div>
412 </div>660 </div>
413 {activeDialogAction === "commit" && (661 {activeDialogAction === "commit" && (
414 <div className="space-y-1">662 <div className="space-y-1">
apps/web/src/components/ui/toast.tsx+4-4
...@@ -37,7 +37,7 @@ interface ToastProviderProps extends Toast.Provider.Props {...@@ -37,7 +37,7 @@ interface ToastProviderProps extends Toast.Provider.Props {
3737
38function ToastProvider({38function ToastProvider({
39 children,39 children,
40 position = "bottom-right",40 position = "top-right",
41 ...props41 ...props
42}: ToastProviderProps) {42}: ToastProviderProps) {
43 return (43 return (
...@@ -48,7 +48,7 @@ function ToastProvider({...@@ -48,7 +48,7 @@ function ToastProvider({
48 );48 );
49}49}
5050
51function Toasts({ position = "bottom-right" }: { position: ToastPosition }) {51function Toasts({ position = "top-right" }: { position: ToastPosition }) {
52 const { toasts } = Toast.useToastManager();52 const { toasts } = Toast.useToastManager();
53 const isTop = position.startsWith("top");53 const isTop = position.startsWith("top");
5454
...@@ -56,9 +56,9 @@ function Toasts({ position = "bottom-right" }: { position: ToastPosition }) {...@@ -56,9 +56,9 @@ function Toasts({ position = "bottom-right" }: { position: ToastPosition }) {
56 <Toast.Portal data-slot="toast-portal">56 <Toast.Portal data-slot="toast-portal">
57 <Toast.Viewport57 <Toast.Viewport
58 className={cn(58 className={cn(
59 "fixed z-50 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]",59 "fixed z-50 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--toast-header-offset:52px] [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]",
60 // Vertical positioning60 // Vertical positioning
61 "data-[position*=top]:top-(--toast-inset)",61 "data-[position*=top]:top-[calc(var(--toast-inset)+var(--toast-header-offset))]",
62 "data-[position*=bottom]:bottom-(--toast-inset)",62 "data-[position*=bottom]:bottom-(--toast-inset)",
63 // Horizontal positioning63 // Horizontal positioning
64 "data-[position*=left]:left-(--toast-inset)",64 "data-[position*=left]:left-(--toast-inset)",
apps/web/src/lib/gitReactQuery.ts created+131
...@@ -0,0 +1,131 @@
1import type { GitStackedAction, NativeApi } from "@t3tools/contracts";
2import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
3
4export const gitQueryKeys = {
5 all: ["git"] as const,
6 status: (cwd: string | null) => ["git", "status", cwd] as const,
7 branches: (cwd: string | null) => ["git", "branches", cwd] as const,
8};
9
10export function invalidateGitQueries(queryClient: QueryClient) {
11 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
12}
13
14export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | null) {
15 return queryOptions({
16 queryKey: gitQueryKeys.status(cwd),
17 queryFn: async () => {
18 if (!api || !cwd) {
19 throw new Error("Git status is unavailable.");
20 }
21 return api.git.status({ cwd });
22 },
23 enabled: !!api && !!cwd,
24 });
25}
26
27export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string | null) {
28 return queryOptions({
29 queryKey: gitQueryKeys.branches(cwd),
30 queryFn: async () => {
31 if (!api || !cwd) {
32 throw new Error("Git branches are unavailable.");
33 }
34 return api.git.listBranches({ cwd });
35 },
36 enabled: !!api && !!cwd,
37 });
38}
39
40export function gitInitMutationOptions(input: {
41 api: NativeApi | undefined;
42 cwd: string | null;
43 queryClient: QueryClient;
44}) {
45 return mutationOptions({
46 mutationFn: async () => {
47 if (!input.api || !input.cwd) throw new Error("Git init is unavailable.");
48 return input.api.git.init({ cwd: input.cwd });
49 },
50 onSuccess: async () => {
51 await invalidateGitQueries(input.queryClient);
52 },
53 });
54}
55
56export function gitCheckoutMutationOptions(input: {
57 api: NativeApi | undefined;
58 cwd: string | null;
59 queryClient: QueryClient;
60}) {
61 return mutationOptions({
62 mutationFn: async (branch: string) => {
63 if (!input.api || !input.cwd) throw new Error("Git checkout is unavailable.");
64 return input.api.git.checkout({ cwd: input.cwd, branch });
65 },
66 onSuccess: async () => {
67 await invalidateGitQueries(input.queryClient);
68 },
69 });
70}
71
72export function gitCreateBranchAndCheckoutMutationOptions(input: {
73 api: NativeApi | undefined;
74 cwd: string | null;
75 queryClient: QueryClient;
76}) {
77 return mutationOptions({
78 mutationFn: async (branch: string) => {
79 if (!input.api || !input.cwd) throw new Error("Git branch creation is unavailable.");
80 await input.api.git.createBranch({ cwd: input.cwd, branch });
81 return input.api.git.checkout({ cwd: input.cwd, branch });
82 },
83 onSuccess: async () => {
84 await invalidateGitQueries(input.queryClient);
85 },
86 });
87}
88
89export function gitRunStackedActionMutationOptions(input: {
90 api: NativeApi | undefined;
91 cwd: string | null;
92 queryClient: QueryClient;
93}) {
94 return mutationOptions({
95 mutationFn: async ({
96 action,
97 commitMessage,
98 }: {
99 action: GitStackedAction;
100 commitMessage?: string;
101 }) => {
102 if (!input.api || !input.cwd) {
103 throw new Error("Git action is unavailable.");
104 }
105 return input.api.git.runStackedAction({
106 cwd: input.cwd,
107 action,
108 ...(commitMessage ? { commitMessage } : {}),
109 });
110 },
111 onSuccess: async () => {
112 await invalidateGitQueries(input.queryClient);
113 },
114 });
115}
116
117export function gitPullMutationOptions(input: {
118 api: NativeApi | undefined;
119 cwd: string | null;
120 queryClient: QueryClient;
121}) {
122 return mutationOptions({
123 mutationFn: async () => {
124 if (!input.api || !input.cwd) throw new Error("Git pull is unavailable.");
125 return input.api.git.pull({ cwd: input.cwd });
126 },
127 onSuccess: async () => {
128 await invalidateGitQueries(input.queryClient);
129 },
130 });
131}
packages/contracts/src/git.test.ts+10
...@@ -25,6 +25,14 @@ describe("git contracts", () => {...@@ -25,6 +25,14 @@ describe("git contracts", () => {
25 const parsed = gitStatusResultSchema.parse({25 const parsed = gitStatusResultSchema.parse({
26 branch: "feature/git-actions",26 branch: "feature/git-actions",
27 hasWorkingTreeChanges: true,27 hasWorkingTreeChanges: true,
28 workingTree: {
29 files: [
30 { path: "src/git.ts", insertions: 8, deletions: 2 },
31 { path: "src/git.test.ts", insertions: 4, deletions: 1 },
32 ],
33 insertions: 12,
34 deletions: 3,
35 },
28 hasUpstream: false,36 hasUpstream: false,
29 aheadCount: 0,37 aheadCount: 0,
30 behindCount: 0,38 behindCount: 0,
...@@ -32,6 +40,8 @@ describe("git contracts", () => {...@@ -32,6 +40,8 @@ describe("git contracts", () => {
32 });40 });
33 expect(parsed.branch).toBe("feature/git-actions");41 expect(parsed.branch).toBe("feature/git-actions");
34 expect(parsed.hasWorkingTreeChanges).toBe(true);42 expect(parsed.hasWorkingTreeChanges).toBe(true);
43 expect(parsed.workingTree.files[0]?.insertions).toBe(8);
44 expect(parsed.workingTree.files).toHaveLength(2);
35 });45 });
3646
37 it("accepts supported stacked actions", () => {47 it("accepts supported stacked actions", () => {
packages/contracts/src/git.ts+11
...@@ -75,6 +75,17 @@ export const gitPullInputSchema = z.object({...@@ -75,6 +75,17 @@ export const gitPullInputSchema = z.object({
75export const gitStatusResultSchema = z.object({75export const gitStatusResultSchema = z.object({
76 branch: z.string().min(1).nullable(),76 branch: z.string().min(1).nullable(),
77 hasWorkingTreeChanges: z.boolean(),77 hasWorkingTreeChanges: z.boolean(),
78 workingTree: z.object({
79 files: z.array(
80 z.object({
81 path: z.string().min(1),
82 insertions: z.number().int().nonnegative(),
83 deletions: z.number().int().nonnegative(),
84 }),
85 ),
86 insertions: z.number().int().nonnegative(),
87 deletions: z.number().int().nonnegative(),
88 }),
78 hasUpstream: z.boolean(),89 hasUpstream: z.boolean(),
79 aheadCount: z.number().int().nonnegative(),90 aheadCount: z.number().int().nonnegative(),
80 behindCount: z.number().int().nonnegative(),91 behindCount: z.number().int().nonnegative(),