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 {...@@ -31,7 +31,7 @@ export interface TerminalCommandInput {
3131
32export type TerminalCommandResult = ProcessRunResult;32export type TerminalCommandResult = ProcessRunResult;
3333
34export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> {34export interface GitStatusDetails extends Omit<GitStatusResult, "openPr" | "mergedPr"> {
35 upstreamRef: string | null;35 upstreamRef: string | null;
36}36}
3737
...@@ -227,6 +227,7 @@ export class GitCoreService {...@@ -227,6 +227,7 @@ export class GitCoreService {
227 aheadCount: details.aheadCount,227 aheadCount: details.aheadCount,
228 behindCount: details.behindCount,228 behindCount: details.behindCount,
229 openPr: null,229 openPr: null,
230 mergedPr: null,
230 });231 });
231 }232 }
232233
apps/server/src/gitManager.test.ts+46-2
...@@ -169,7 +169,7 @@ describe("GitManager", () => {...@@ -169,7 +169,7 @@ describe("GitManager", () => {
169 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);169 await runGit(repoDir, ["remote", "add", "origin", remoteDir]);
170 await runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]);170 await runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]);
171171
172 const { runner } = createRunnerWithFakeGh({172 const { runner, ghCalls } = createRunnerWithFakeGh({
173 prListSequence: [173 prListSequence: [
174 JSON.stringify([174 JSON.stringify([
175 {175 {
...@@ -197,9 +197,52 @@ describe("GitManager", () => {...@@ -197,9 +197,52 @@ describe("GitManager", () => {
197 baseBranch: "main",197 baseBranch: "main",
198 headBranch: "feature/status-open-pr",198 headBranch: "feature/status-open-pr",
199 });199 });
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);
200 });243 });
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 () => {
203 const repoDir = makeTempDir("t3code-git-manager-");246 const repoDir = makeTempDir("t3code-git-manager-");
204 await initRepo(repoDir);247 await initRepo(repoDir);
205 await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]);248 await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]);
...@@ -218,6 +261,7 @@ describe("GitManager", () => {...@@ -218,6 +261,7 @@ describe("GitManager", () => {
218 const status = await manager.status({ cwd: repoDir });261 const status = await manager.status({ cwd: repoDir });
219 expect(status.branch).toBe("feature/status-no-gh");262 expect(status.branch).toBe("feature/status-no-gh");
220 expect(status.openPr).toBeNull();263 expect(status.openPr).toBeNull();
264 expect(status.mergedPr).toBeNull();
221 });265 });
222266
223 it("creates a commit when working tree is dirty", async () => {267 it("creates a commit when working tree is dirty", async () => {
apps/server/src/gitManager.ts+36-1
...@@ -192,11 +192,17 @@ export class GitManager {...@@ -192,11 +192,17 @@ export class GitManager {
192 const details = await this.gitCore.statusDetails(input.cwd);192 const details = await this.gitCore.statusDetails(input.cwd);
193193
194 let openPr: ReturnType<typeof toStatusOpenPr> | null = null;194 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) {
196 try {197 try {
197 const existing = await this.findOpenPr(input.cwd, details.branch);198 const existing = await this.findOpenPr(input.cwd, details.branch);
198 if (existing) {199 if (existing) {
199 openPr = toStatusOpenPr(existing);200 openPr = toStatusOpenPr(existing);
201 } else {
202 const merged = await this.findMergedPr(input.cwd, details.branch);
203 if (merged) {
204 mergedPr = toStatusOpenPr(merged);
205 }
200 }206 }
201 } catch {207 } catch {
202 // PR lookup is best-effort for status rendering.208 // PR lookup is best-effort for status rendering.
...@@ -211,6 +217,7 @@ export class GitManager {...@@ -211,6 +217,7 @@ export class GitManager {
211 aheadCount: details.aheadCount,217 aheadCount: details.aheadCount,
212 behindCount: details.behindCount,218 behindCount: details.behindCount,
213 openPr,219 openPr,
220 mergedPr,
214 };221 };
215 }222 }
216223
...@@ -408,6 +415,34 @@ export class GitManager {...@@ -408,6 +415,34 @@ export class GitManager {
408 return parsed[0] ?? null;415 return parsed[0] ?? null;
409 }416 }
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
411 private async resolveBaseBranch(446 private async resolveBaseBranch(
412 cwd: string,447 cwd: string,
413 branch: string,448 branch: string,
apps/server/src/wsServer.test.ts+2
...@@ -564,6 +564,7 @@ describe("WebSocket Server", () => {...@@ -564,6 +564,7 @@ describe("WebSocket Server", () => {
564 aheadCount: 0,564 aheadCount: 0,
565 behindCount: 0,565 behindCount: 0,
566 openPr: null,566 openPr: null,
567 mergedPr: null,
567 }),568 }),
568 runStackedAction: vi.fn(),569 runStackedAction: vi.fn(),
569 };570 };
...@@ -593,6 +594,7 @@ describe("WebSocket Server", () => {...@@ -593,6 +594,7 @@ describe("WebSocket Server", () => {
593 aheadCount: 0,594 aheadCount: 0,
594 behindCount: 0,595 behindCount: 0,
595 openPr: null,596 openPr: null,
597 mergedPr: null,
596 });598 });
597 expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });599 expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });
598 });600 });
apps/web/src/components/GitActionsControl.logic.test.ts+1
...@@ -21,6 +21,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {...@@ -21,6 +21,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
21 aheadCount: 0,21 aheadCount: 0,
22 behindCount: 0,22 behindCount: 0,
23 openPr: null,23 openPr: null,
24 mergedPr: null,
24 ...overrides,25 ...overrides,
25 };26 };
26}27}
apps/web/src/components/Sidebar.tsx+55-4
...@@ -1,6 +1,7 @@...@@ -1,6 +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, useQueryClient } from "@tanstack/react-query";3import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
4import type { GitStatusResult } from "@t3tools/contracts";
4import { isElectron } from "../env";5import { isElectron } from "../env";
5import { useTheme } from "../hooks/useTheme";6import { useTheme } from "../hooks/useTheme";
6import { DEFAULT_MODEL } from "../model-logic";7import { DEFAULT_MODEL } from "../model-logic";
...@@ -13,7 +14,7 @@ import {...@@ -13,7 +14,7 @@ import {
13 type Thread,14 type Thread,
14} from "../types";15} from "../types";
15import { useNativeApi } from "../hooks/useNativeApi";16import { useNativeApi } from "../hooks/useNativeApi";
16import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";17import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
17import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";18import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1819
19const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;20const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
...@@ -35,7 +36,7 @@ function inferProjectName(cwd: string): string {...@@ -35,7 +36,7 @@ function inferProjectName(cwd: string): string {
35}36}
3637
37interface ThreadStatusPill {38interface ThreadStatusPill {
38 label: "Working" | "Connecting" | "Completed" | "Awaiting response";39 label: "Working" | "Connecting" | "Completed" | "Awaiting response" | "Merged";
39 colorClass: string;40 colorClass: string;
40 dotClass: string;41 dotClass: string;
41 pulse: boolean;42 pulse: boolean;
...@@ -58,7 +59,11 @@ function hasUnseenCompletion(thread: Thread): boolean {...@@ -58,7 +59,11 @@ function hasUnseenCompletion(thread: Thread): boolean {
58 return completedAt > lastVisitedAt;59 return completedAt > lastVisitedAt;
59}60}
6061
61function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null {62function threadStatusPill(
63 thread: Thread,
64 hasPendingApprovals: boolean,
65 hasMergedPr: boolean,
66): ThreadStatusPill | null {
62 if (hasPendingApprovals) {67 if (hasPendingApprovals) {
63 return {68 return {
64 label: "Awaiting response",69 label: "Awaiting response",
...@@ -86,6 +91,15 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS...@@ -86,6 +91,15 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS
86 };91 };
87 }92 }
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
89 if (hasUnseenCompletion(thread)) {103 if (hasUnseenCompletion(thread)) {
90 return {104 return {
91 label: "Completed",105 label: "Completed",
...@@ -126,6 +140,42 @@ export default function Sidebar() {...@@ -126,6 +140,42 @@ export default function Sidebar() {
126 }140 }
127 return map;141 return map;
128 }, [state.threads]);142 }, [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
130 const handleNewThread = useCallback(180 const handleNewThread = useCallback(
131 (projectId: string) => {181 (projectId: string) => {
...@@ -426,6 +476,7 @@ export default function Sidebar() {...@@ -426,6 +476,7 @@ export default function Sidebar() {
426 const threadStatus = threadStatusPill(476 const threadStatus = threadStatusPill(
427 thread,477 thread,
428 pendingApprovalByThreadId.get(thread.id) === true,478 pendingApprovalByThreadId.get(thread.id) === true,
479 mergedPrByThreadId.get(thread.id) === true,
429 );480 );
430 const terminalStatus = terminalStatusIndicator(thread);481 const terminalStatus = terminalStatusIndicator(thread);
431 return (482 return (
packages/contracts/src/git.test.ts+1
...@@ -37,6 +37,7 @@ describe("git contracts", () => {...@@ -37,6 +37,7 @@ describe("git contracts", () => {
37 aheadCount: 0,37 aheadCount: 0,
38 behindCount: 0,38 behindCount: 0,
39 openPr: null,39 openPr: null,
40 mergedPr: null,
40 });41 });
41 expect(parsed.branch).toBe("feature/git-actions");42 expect(parsed.branch).toBe("feature/git-actions");
42 expect(parsed.hasWorkingTreeChanges).toBe(true);43 expect(parsed.hasWorkingTreeChanges).toBe(true);
packages/contracts/src/git.ts+9
...@@ -98,6 +98,15 @@ export const gitStatusResultSchema = z.object({...@@ -98,6 +98,15 @@ export const gitStatusResultSchema = z.object({
98 headBranch: z.string().min(1),98 headBranch: z.string().min(1),
99 })99 })
100 .nullable(),100 .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(),
101});110});
102111
103export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]);112export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]);