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", () => {...@@ -242,6 +242,28 @@ describe("GitManager", () => {
242 expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true);242 expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true);
243 });243 });
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
245 it("status is resilient to gh lookup failures and returns openPr/mergedPr null", async () => {267 it("status is resilient to gh lookup failures and returns openPr/mergedPr null", async () => {
246 const repoDir = makeTempDir("t3code-git-manager-");268 const repoDir = makeTempDir("t3code-git-manager-");
247 await initRepo(repoDir);269 await initRepo(repoDir);
apps/server/src/gitManager.ts+7-4
...@@ -195,10 +195,13 @@ export class GitManager {...@@ -195,10 +195,13 @@ export class GitManager {
195 let mergedPr: ReturnType<typeof toStatusOpenPr> | null = null;195 let mergedPr: ReturnType<typeof toStatusOpenPr> | null = null;
196 if (details.branch) {196 if (details.branch) {
197 try {197 try {
198 const existing = await this.findOpenPr(input.cwd, details.branch);198 if (details.hasUpstream) {
199 if (existing) {199 const existing = await this.findOpenPr(input.cwd, details.branch);
200 openPr = toStatusOpenPr(existing);200 if (existing) {
201 } else {201 openPr = toStatusOpenPr(existing);
202 }
203 }
204 if (!openPr) {
202 const merged = await this.findMergedPr(input.cwd, details.branch);205 const merged = await this.findMergedPr(input.cwd, details.branch);
203 if (merged) {206 if (merged) {
204 mergedPr = toStatusOpenPr(merged);207 mergedPr = toStatusOpenPr(merged);
apps/web/src/components/Sidebar.tsx+29-9
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";1import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
2import { useCallback, useEffect, useMemo, useState } from "react";2import { useCallback, useEffect, useMemo, useState } from "react";
3import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";3import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
4import type { GitStatusResult } from "@t3tools/contracts";4import { gitStatusResultSchema, type GitStatusResult } from "@t3tools/contracts";
5import { isElectron } from "../env";5import { isElectron } from "../env";
6import { useTheme } from "../hooks/useTheme";6import { useTheme } from "../hooks/useTheme";
7import { DEFAULT_MODEL } from "../model-logic";7import { DEFAULT_MODEL } from "../model-logic";
...@@ -153,29 +153,49 @@ export default function Sidebar() {...@@ -153,29 +153,49 @@ export default function Sidebar() {
153 })),153 })),
154 [projectCwdById, state.threads],154 [projectCwdById, state.threads],
155 );155 );
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 );
156 const threadGitStatusQueries = useQueries({168 const threadGitStatusQueries = useQueries({
157 queries: threadGitTargets.map((target) => {169 queries: threadGitStatusCwds.map((cwd) => {
158 const base = gitStatusQueryOptions(api, target.cwd);170 const base = gitStatusQueryOptions(api, cwd);
159 return {171 return {
160 ...base,172 ...base,
161 enabled: Boolean(base.enabled && target.branch !== null),173 enabled: Boolean(base.enabled),
162 staleTime: 30_000,174 staleTime: 30_000,
163 refetchInterval: 60_000,175 refetchInterval: 60_000,
164 };176 };
165 }),177 }),
166 });178 });
167 const mergedPrByThreadId = useMemo(() => {179 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
168 const map = new Map<string, boolean>();190 const map = new Map<string, boolean>();
169 for (let index = 0; index < threadGitTargets.length; index += 1) {191 for (const target of threadGitTargets) {
170 const target = threadGitTargets[index];192 const status = target.cwd ? statusByCwd.get(target.cwd) : undefined;
171 if (!target) continue;
172 const status = threadGitStatusQueries[index]?.data as GitStatusResult | undefined;
173 const branchMatches =193 const branchMatches =
174 target.branch !== null && status?.branch !== null && status?.branch === target.branch;194 target.branch !== null && status?.branch !== null && status?.branch === target.branch;
175 map.set(target.threadId, branchMatches && status?.openPr === null && status?.mergedPr !== null);195 map.set(target.threadId, branchMatches && status?.openPr === null && status?.mergedPr !== null);
176 }196 }
177 return map;197 return map;
178 }, [threadGitStatusQueries, threadGitTargets]);198 }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);
179199
180 const handleNewThread = useCallback(200 const handleNewThread = useCallback(
181 (projectId: string) => {201 (projectId: string) => {