authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 14:04:48-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 14:04:48-08:00
log46347e2bd273dd387d52b265691ab456d3e936be
tree93a0eec5dd1f4de200266ae37bf5ab1f8c87339a
parent7fc53dadc7d66483cdeac70f233059490785c9e2

Show merged PR status when no open PR exists

- extend `GitStatusResult` with nullable `mergedPr` metadata - in server git status, query merged PR only when no open PR is found - in sidebar, display a `Merged` status pill for threads on branches with merged PRs - update server/web/contracts tests for the new status shape

8 files changed, 152 insertions(+), 8 deletions(-)

apps/server/src/git.ts+2-1
......@@ -31,7 +31,7 @@ export interface TerminalCommandInput {
3131
3232export type TerminalCommandResult = ProcessRunResult;
3333
34export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> {
34export interface GitStatusDetails extends Omit<GitStatusResult, "openPr" | "mergedPr"> {
3535 upstreamRef: string | null;
3636}
3737
......@@ -227,6 +227,7 @@ export class GitCoreService {
227227 aheadCount: details.aheadCount,
228228 behindCount: details.behindCount,
229229 openPr: null,
230 mergedPr: null,
230231 });
231232 }
232233
apps/server/src/gitManager.test.ts+46-2
......@@ -169,7 +169,7 @@ describe("GitManager", () => {
169169 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
170170 await runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]);
171171
172 const { runner } = createRunnerWithFakeGh({
172 const { runner, ghCalls } = createRunnerWithFakeGh({
173173 prListSequence: [
174174 JSON.stringify([
175175 {
......@@ -197,9 +197,52 @@ describe("GitManager", () => {
197197 baseBranch: "main",
198198 headBranch: "feature/status-open-pr",
199199 });
200 expect(status.mergedPr).toBeNull();
201 expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(false);
202 });
203
204 it("status includes merged PR metadata when there is no open PR", async () => {
205 const repoDir = makeTempDir("t3code-git-manager-");
206 await initRepo(repoDir);
207 await runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]);
208 const remoteDir = await createBareRemote();
209 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
210 await runGit(repoDir, ["push", "-u", "origin", "feature/status-merged-pr"]);
211
212 const { runner, ghCalls } = createRunnerWithFakeGh({
213 prListSequence: [
214 "[]",
215 JSON.stringify([
216 {
217 number: 26,
218 title: "Merged PR",
219 url: "https://github.com/pingdotgg/codething-mvp/pull/26",
220 baseRefName: "main",
221 headRefName: "feature/status-merged-pr",
222 },
223 ]),
224 ],
225 });
226
227 const manager = new GitManager({
228 runProcess: runner,
229 textGenerator: createTextGenerator(),
230 });
231
232 const status = await manager.status({ cwd: repoDir });
233 expect(status.branch).toBe("feature/status-merged-pr");
234 expect(status.openPr).toBeNull();
235 expect(status.mergedPr).toEqual({
236 number: 26,
237 title: "Merged PR",
238 url: "https://github.com/pingdotgg/codething-mvp/pull/26",
239 baseBranch: "main",
240 headBranch: "feature/status-merged-pr",
241 });
242 expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true);
200243 });
201244
202 it("status is resilient to gh lookup failures and returns openPr null", async () => {
245 it("status is resilient to gh lookup failures and returns openPr/mergedPr null", async () => {
203246 const repoDir = makeTempDir("t3code-git-manager-");
204247 await initRepo(repoDir);
205248 await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]);
......@@ -218,6 +261,7 @@ describe("GitManager", () => {
218261 const status = await manager.status({ cwd: repoDir });
219262 expect(status.branch).toBe("feature/status-no-gh");
220263 expect(status.openPr).toBeNull();
264 expect(status.mergedPr).toBeNull();
221265 });
222266
223267 it("creates a commit when working tree is dirty", async () => {
apps/server/src/gitManager.ts+36-1
......@@ -192,11 +192,17 @@ export class GitManager {
192192 const details = await this.gitCore.statusDetails(input.cwd);
193193
194194 let openPr: ReturnType<typeof toStatusOpenPr> | null = null;
195 if (details.branch && details.hasUpstream) {
195 let mergedPr: ReturnType<typeof toStatusOpenPr> | null = null;
196 if (details.branch) {
196197 try {
197198 const existing = await this.findOpenPr(input.cwd, details.branch);
198199 if (existing) {
199200 openPr = toStatusOpenPr(existing);
201 } else {
202 const merged = await this.findMergedPr(input.cwd, details.branch);
203 if (merged) {
204 mergedPr = toStatusOpenPr(merged);
205 }
200206 }
201207 } catch {
202208 // PR lookup is best-effort for status rendering.
......@@ -211,6 +217,7 @@ export class GitManager {
211217 aheadCount: details.aheadCount,
212218 behindCount: details.behindCount,
213219 openPr,
220 mergedPr,
214221 };
215222 }
216223
......@@ -408,6 +415,34 @@ export class GitManager {
408415 return parsed[0] ?? null;
409416 }
410417
418 private async findMergedPr(cwd: string, branch: string): Promise<OpenPrInfo | null> {
419 const stdout = await this.runGhStdout(cwd, [
420 "pr",
421 "list",
422 "--head",
423 branch,
424 "--state",
425 "merged",
426 "--limit",
427 "1",
428 "--json",
429 "number,title,url,baseRefName,headRefName",
430 ]);
431
432 const raw = trimStdout(stdout);
433 if (raw.length === 0) return null;
434
435 let parsedJson: unknown;
436 try {
437 parsedJson = JSON.parse(raw);
438 } catch {
439 throw new Error("GitHub CLI returned invalid PR list JSON.");
440 }
441
442 const parsed = parseOpenPrList(parsedJson);
443 return parsed[0] ?? null;
444 }
445
411446 private async resolveBaseBranch(
412447 cwd: string,
413448 branch: string,
apps/server/src/wsServer.test.ts+2
......@@ -564,6 +564,7 @@ describe("WebSocket Server", () => {
564564 aheadCount: 0,
565565 behindCount: 0,
566566 openPr: null,
567 mergedPr: null,
567568 }),
568569 runStackedAction: vi.fn(),
569570 };
......@@ -593,6 +594,7 @@ describe("WebSocket Server", () => {
593594 aheadCount: 0,
594595 behindCount: 0,
595596 openPr: null,
597 mergedPr: null,
596598 });
597599 expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });
598600 });
apps/web/src/components/GitActionsControl.logic.test.ts+1
......@@ -21,6 +21,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
2121 aheadCount: 0,
2222 behindCount: 0,
2323 openPr: null,
24 mergedPr: null,
2425 ...overrides,
2526 };
2627}
apps/web/src/components/Sidebar.tsx+55-4
......@@ -1,6 +1,7 @@
11import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
22import { useCallback, useEffect, useMemo, useState } from "react";
3import { useMutation, useQueryClient } from "@tanstack/react-query";
3import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
4import type { GitStatusResult } from "@t3tools/contracts";
45import { isElectron } from "../env";
56import { useTheme } from "../hooks/useTheme";
67import { DEFAULT_MODEL } from "../model-logic";
......@@ -13,7 +14,7 @@ import {
1314 type Thread,
1415} from "../types";
1516import { useNativeApi } from "../hooks/useNativeApi";
16import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
17import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
1718import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1819
1920const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
......@@ -35,7 +36,7 @@ function inferProjectName(cwd: string): string {
3536}
3637
3738interface ThreadStatusPill {
38 label: "Working" | "Connecting" | "Completed" | "Awaiting response";
39 label: "Working" | "Connecting" | "Completed" | "Awaiting response" | "Merged";
3940 colorClass: string;
4041 dotClass: string;
4142 pulse: boolean;
......@@ -58,7 +59,11 @@ function hasUnseenCompletion(thread: Thread): boolean {
5859 return completedAt > lastVisitedAt;
5960}
6061
61function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null {
62function threadStatusPill(
63 thread: Thread,
64 hasPendingApprovals: boolean,
65 hasMergedPr: boolean,
66): ThreadStatusPill | null {
6267 if (hasPendingApprovals) {
6368 return {
6469 label: "Awaiting response",
......@@ -86,6 +91,15 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS
8691 };
8792 }
8893
94 if (hasMergedPr) {
95 return {
96 label: "Merged",
97 colorClass: "text-violet-600 dark:text-violet-300/90",
98 dotClass: "bg-violet-500 dark:bg-violet-300/90",
99 pulse: false,
100 };
101 }
102
89103 if (hasUnseenCompletion(thread)) {
90104 return {
91105 label: "Completed",
......@@ -126,6 +140,42 @@ export default function Sidebar() {
126140 }
127141 return map;
128142 }, [state.threads]);
143 const projectCwdById = useMemo(
144 () => new Map(state.projects.map((project) => [project.id, project.cwd] as const)),
145 [state.projects],
146 );
147 const threadGitTargets = useMemo(
148 () =>
149 state.threads.map((thread) => ({
150 threadId: thread.id,
151 branch: thread.branch,
152 cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null,
153 })),
154 [projectCwdById, state.threads],
155 );
156 const threadGitStatusQueries = useQueries({
157 queries: threadGitTargets.map((target) => {
158 const base = gitStatusQueryOptions(api, target.cwd);
159 return {
160 ...base,
161 enabled: Boolean(base.enabled && target.branch !== null),
162 staleTime: 30_000,
163 refetchInterval: 60_000,
164 };
165 }),
166 });
167 const mergedPrByThreadId = useMemo(() => {
168 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;
173 const branchMatches =
174 target.branch !== null && status?.branch !== null && status?.branch === target.branch;
175 map.set(target.threadId, branchMatches && status?.openPr === null && status?.mergedPr !== null);
176 }
177 return map;
178 }, [threadGitStatusQueries, threadGitTargets]);
129179
130180 const handleNewThread = useCallback(
131181 (projectId: string) => {
......@@ -426,6 +476,7 @@ export default function Sidebar() {
426476 const threadStatus = threadStatusPill(
427477 thread,
428478 pendingApprovalByThreadId.get(thread.id) === true,
479 mergedPrByThreadId.get(thread.id) === true,
429480 );
430481 const terminalStatus = terminalStatusIndicator(thread);
431482 return (
packages/contracts/src/git.test.ts+1
......@@ -37,6 +37,7 @@ describe("git contracts", () => {
3737 aheadCount: 0,
3838 behindCount: 0,
3939 openPr: null,
40 mergedPr: null,
4041 });
4142 expect(parsed.branch).toBe("feature/git-actions");
4243 expect(parsed.hasWorkingTreeChanges).toBe(true);
packages/contracts/src/git.ts+9
......@@ -98,6 +98,15 @@ export const gitStatusResultSchema = z.object({
9898 headBranch: z.string().min(1),
9999 })
100100 .nullable(),
101 mergedPr: z
102 .object({
103 number: z.number().int().positive(),
104 title: z.string().min(1),
105 url: z.string().url(),
106 baseBranch: z.string().min(1),
107 headBranch: z.string().min(1),
108 })
109 .nullable(),
101110});
102111
103112export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]);