authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 14:28:03-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 14:28:03-08:00
logdcdb9ac92320d62028fd2d12be26167406666920
tree33c34b22e0006565ac051ea31ffe6b8bbc1038e1
parent46347e2bd273dd387d52b265691ab456d3e936be

Skip local-branch PR checks and dedupe sidebar git status

- server: only query open PRs when the current branch has an upstream; still fall back to merged PR lookup - tests: cover status behavior for branches without upstream tracking - web: fetch git status once per cwd, validate query data with schema parsing, and map merged PR badges by thread branch match

3 files changed, 58 insertions(+), 13 deletions(-)

apps/server/src/gitManager.test.ts+22
......@@ -242,6 +242,28 @@ describe("GitManager", () => {
242242 expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true);
243243 });
244244
245 it("status skips open PR lookup for branches without upstream", async () => {
246 const repoDir = makeTempDir("t3code-git-manager-");
247 await initRepo(repoDir);
248 await runGit(repoDir, ["checkout", "-b", "feature/local-only"]);
249
250 const { runner, ghCalls } = createRunnerWithFakeGh({
251 prListSequence: ["[]"],
252 });
253
254 const manager = new GitManager({
255 runProcess: runner,
256 textGenerator: createTextGenerator(),
257 });
258
259 const status = await manager.status({ cwd: repoDir });
260 expect(status.branch).toBe("feature/local-only");
261 expect(status.openPr).toBeNull();
262 expect(status.mergedPr).toBeNull();
263 expect(ghCalls.some((call) => call.includes("--state open"))).toBe(false);
264 expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true);
265 });
266
245267 it("status is resilient to gh lookup failures and returns openPr/mergedPr null", async () => {
246268 const repoDir = makeTempDir("t3code-git-manager-");
247269 await initRepo(repoDir);
apps/server/src/gitManager.ts+7-4
......@@ -195,10 +195,13 @@ export class GitManager {
195195 let mergedPr: ReturnType<typeof toStatusOpenPr> | null = null;
196196 if (details.branch) {
197197 try {
198 const existing = await this.findOpenPr(input.cwd, details.branch);
199 if (existing) {
200 openPr = toStatusOpenPr(existing);
201 } else {
198 if (details.hasUpstream) {
199 const existing = await this.findOpenPr(input.cwd, details.branch);
200 if (existing) {
201 openPr = toStatusOpenPr(existing);
202 }
203 }
204 if (!openPr) {
202205 const merged = await this.findMergedPr(input.cwd, details.branch);
203206 if (merged) {
204207 mergedPr = toStatusOpenPr(merged);
apps/web/src/components/Sidebar.tsx+29-9
......@@ -1,7 +1,7 @@
11import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
22import { useCallback, useEffect, useMemo, useState } from "react";
33import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
4import type { GitStatusResult } from "@t3tools/contracts";
4import { gitStatusResultSchema, type GitStatusResult } from "@t3tools/contracts";
55import { isElectron } from "../env";
66import { useTheme } from "../hooks/useTheme";
77import { DEFAULT_MODEL } from "../model-logic";
......@@ -153,29 +153,49 @@ export default function Sidebar() {
153153 })),
154154 [projectCwdById, state.threads],
155155 );
156 const threadGitStatusCwds = useMemo(
157 () =>
158 [
159 ...new Set(
160 threadGitTargets
161 .filter((target) => target.branch !== null)
162 .map((target) => target.cwd)
163 .filter((cwd): cwd is string => cwd !== null),
164 ),
165 ],
166 [threadGitTargets],
167 );
156168 const threadGitStatusQueries = useQueries({
157 queries: threadGitTargets.map((target) => {
158 const base = gitStatusQueryOptions(api, target.cwd);
169 queries: threadGitStatusCwds.map((cwd) => {
170 const base = gitStatusQueryOptions(api, cwd);
159171 return {
160172 ...base,
161 enabled: Boolean(base.enabled && target.branch !== null),
173 enabled: Boolean(base.enabled),
162174 staleTime: 30_000,
163175 refetchInterval: 60_000,
164176 };
165177 }),
166178 });
167179 const mergedPrByThreadId = useMemo(() => {
180 const statusByCwd = new Map<string, GitStatusResult>();
181 for (let index = 0; index < threadGitStatusCwds.length; index += 1) {
182 const cwd = threadGitStatusCwds[index];
183 if (!cwd) continue;
184 const parsed = gitStatusResultSchema.safeParse(threadGitStatusQueries[index]?.data);
185 if (parsed.success) {
186 statusByCwd.set(cwd, parsed.data);
187 }
188 }
189
168190 const map = new Map<string, boolean>();
169 for (let index = 0; index < threadGitTargets.length; index += 1) {
170 const target = threadGitTargets[index];
171 if (!target) continue;
172 const status = threadGitStatusQueries[index]?.data as GitStatusResult | undefined;
191 for (const target of threadGitTargets) {
192 const status = target.cwd ? statusByCwd.get(target.cwd) : undefined;
173193 const branchMatches =
174194 target.branch !== null && status?.branch !== null && status?.branch === target.branch;
175195 map.set(target.threadId, branchMatches && status?.openPr === null && status?.mergedPr !== null);
176196 }
177197 return map;
178 }, [threadGitStatusQueries, threadGitTargets]);
198 }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);
179199
180200 const handleNewThread = useCallback(
181201 (projectId: string) => {