From 46347e2bd273dd387d52b265691ab456d3e936be Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 14:04:48 -0800 Subject: [PATCH 1/4] 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 --- apps/server/src/git.ts | 3 +- apps/server/src/gitManager.test.ts | 48 ++++++++++++++- apps/server/src/gitManager.ts | 37 +++++++++++- apps/server/src/wsServer.test.ts | 2 + .../GitActionsControl.logic.test.ts | 1 + apps/web/src/components/Sidebar.tsx | 59 +++++++++++++++++-- packages/contracts/src/git.test.ts | 1 + packages/contracts/src/git.ts | 9 +++ 8 files changed, 152 insertions(+), 8 deletions(-) diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts index 1fbd86c7bee1..cad9cad4c344 100644 --- a/apps/server/src/git.ts +++ b/apps/server/src/git.ts @@ -31,7 +31,7 @@ export interface TerminalCommandInput { export type TerminalCommandResult = ProcessRunResult; -export interface GitStatusDetails extends Omit { +export interface GitStatusDetails extends Omit { upstreamRef: string | null; } @@ -227,6 +227,7 @@ export class GitCoreService { aheadCount: details.aheadCount, behindCount: details.behindCount, openPr: null, + mergedPr: null, }); } diff --git a/apps/server/src/gitManager.test.ts b/apps/server/src/gitManager.test.ts index f2d399c8b876..961f74fb9de0 100644 --- a/apps/server/src/gitManager.test.ts +++ b/apps/server/src/gitManager.test.ts @@ -169,7 +169,7 @@ describe("GitManager", () => { await runGit(repoDir, ["remote", "add", "origin", remoteDir]); await runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); - const { runner } = createRunnerWithFakeGh({ + const { runner, ghCalls } = createRunnerWithFakeGh({ prListSequence: [ JSON.stringify([ { @@ -197,9 +197,52 @@ describe("GitManager", () => { baseBranch: "main", headBranch: "feature/status-open-pr", }); + expect(status.mergedPr).toBeNull(); + expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(false); + }); + + it("status includes merged PR metadata when there is no open PR", async () => { + const repoDir = makeTempDir("t3code-git-manager-"); + await initRepo(repoDir); + await runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]); + const remoteDir = await createBareRemote(); + await runGit(repoDir, ["remote", "add", "origin", remoteDir]); + await runGit(repoDir, ["push", "-u", "origin", "feature/status-merged-pr"]); + + const { runner, ghCalls } = createRunnerWithFakeGh({ + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 26, + title: "Merged PR", + url: "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/pingdotgg/codething-mvp/pull/26", + baseRefName: "main", + headRefName: "feature/status-merged-pr", + }, + ]), + ], + }); + + const manager = new GitManager({ + runProcess: runner, + textGenerator: createTextGenerator(), + }); + + const status = await manager.status({ cwd: repoDir }); + expect(status.branch).toBe("feature/status-merged-pr"); + expect(status.openPr).toBeNull(); + expect(status.mergedPr).toEqual({ + number: 26, + title: "Merged PR", + url: "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/pingdotgg/codething-mvp/pull/26", + baseBranch: "main", + headBranch: "feature/status-merged-pr", + }); + expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true); }); - it("status is resilient to gh lookup failures and returns openPr null", async () => { + it("status is resilient to gh lookup failures and returns openPr/mergedPr null", async () => { const repoDir = makeTempDir("t3code-git-manager-"); await initRepo(repoDir); await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); @@ -218,6 +261,7 @@ describe("GitManager", () => { const status = await manager.status({ cwd: repoDir }); expect(status.branch).toBe("feature/status-no-gh"); expect(status.openPr).toBeNull(); + expect(status.mergedPr).toBeNull(); }); it("creates a commit when working tree is dirty", async () => { diff --git a/apps/server/src/gitManager.ts b/apps/server/src/gitManager.ts index a9d7689513a0..d163b8ee9dd5 100644 --- a/apps/server/src/gitManager.ts +++ b/apps/server/src/gitManager.ts @@ -192,11 +192,17 @@ export class GitManager { const details = await this.gitCore.statusDetails(input.cwd); let openPr: ReturnType | null = null; - if (details.branch && details.hasUpstream) { + let mergedPr: ReturnType | null = null; + if (details.branch) { try { const existing = await this.findOpenPr(input.cwd, details.branch); if (existing) { openPr = toStatusOpenPr(existing); + } else { + const merged = await this.findMergedPr(input.cwd, details.branch); + if (merged) { + mergedPr = toStatusOpenPr(merged); + } } } catch { // PR lookup is best-effort for status rendering. @@ -211,6 +217,7 @@ export class GitManager { aheadCount: details.aheadCount, behindCount: details.behindCount, openPr, + mergedPr, }; } @@ -408,6 +415,34 @@ export class GitManager { return parsed[0] ?? null; } + private async findMergedPr(cwd: string, branch: string): Promise { + const stdout = await this.runGhStdout(cwd, [ + "pr", + "list", + "--head", + branch, + "--state", + "merged", + "--limit", + "1", + "--json", + "number,title,url,baseRefName,headRefName", + ]); + + const raw = trimStdout(stdout); + if (raw.length === 0) return null; + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(raw); + } catch { + throw new Error("GitHub CLI returned invalid PR list JSON."); + } + + const parsed = parseOpenPrList(parsedJson); + return parsed[0] ?? null; + } + private async resolveBaseBranch( cwd: string, branch: string, diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index baf70eaca2b3..169a6b2bd4ec 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -564,6 +564,7 @@ describe("WebSocket Server", () => { aheadCount: 0, behindCount: 0, openPr: null, + mergedPr: null, }), runStackedAction: vi.fn(), }; @@ -593,6 +594,7 @@ describe("WebSocket Server", () => { aheadCount: 0, behindCount: 0, openPr: null, + mergedPr: null, }); expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index b764fac276bc..feac3a689bd2 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -21,6 +21,7 @@ function status(overrides: Partial = {}): GitStatusResult { aheadCount: 0, behindCount: 0, openPr: null, + mergedPr: null, ...overrides, }; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e851468280cf..a0bd90740e91 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; +import type { GitStatusResult } from "@t3tools/contracts"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; @@ -13,7 +14,7 @@ import { type Thread, } from "../types"; import { useNativeApi } from "../hooks/useNativeApi"; -import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; +import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; @@ -35,7 +36,7 @@ function inferProjectName(cwd: string): string { } interface ThreadStatusPill { - label: "Working" | "Connecting" | "Completed" | "Awaiting response"; + label: "Working" | "Connecting" | "Completed" | "Awaiting response" | "Merged"; colorClass: string; dotClass: string; pulse: boolean; @@ -58,7 +59,11 @@ function hasUnseenCompletion(thread: Thread): boolean { return completedAt > lastVisitedAt; } -function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null { +function threadStatusPill( + thread: Thread, + hasPendingApprovals: boolean, + hasMergedPr: boolean, +): ThreadStatusPill | null { if (hasPendingApprovals) { return { label: "Awaiting response", @@ -86,6 +91,15 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS }; } + if (hasMergedPr) { + return { + label: "Merged", + colorClass: "text-violet-600 dark:text-violet-300/90", + dotClass: "bg-violet-500 dark:bg-violet-300/90", + pulse: false, + }; + } + if (hasUnseenCompletion(thread)) { return { label: "Completed", @@ -126,6 +140,42 @@ export default function Sidebar() { } return map; }, [state.threads]); + const projectCwdById = useMemo( + () => new Map(state.projects.map((project) => [project.id, project.cwd] as const)), + [state.projects], + ); + const threadGitTargets = useMemo( + () => + state.threads.map((thread) => ({ + threadId: thread.id, + branch: thread.branch, + cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, + })), + [projectCwdById, state.threads], + ); + const threadGitStatusQueries = useQueries({ + queries: threadGitTargets.map((target) => { + const base = gitStatusQueryOptions(api, target.cwd); + return { + ...base, + enabled: Boolean(base.enabled && target.branch !== null), + staleTime: 30_000, + refetchInterval: 60_000, + }; + }), + }); + const mergedPrByThreadId = useMemo(() => { + const map = new Map(); + for (let index = 0; index < threadGitTargets.length; index += 1) { + const target = threadGitTargets[index]; + if (!target) continue; + const status = threadGitStatusQueries[index]?.data as GitStatusResult | undefined; + const branchMatches = + target.branch !== null && status?.branch !== null && status?.branch === target.branch; + map.set(target.threadId, branchMatches && status?.openPr === null && status?.mergedPr !== null); + } + return map; + }, [threadGitStatusQueries, threadGitTargets]); const handleNewThread = useCallback( (projectId: string) => { @@ -426,6 +476,7 @@ export default function Sidebar() { const threadStatus = threadStatusPill( thread, pendingApprovalByThreadId.get(thread.id) === true, + mergedPrByThreadId.get(thread.id) === true, ); const terminalStatus = terminalStatusIndicator(thread); return ( diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 596f9c8aa96b..05b467c35833 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -37,6 +37,7 @@ describe("git contracts", () => { aheadCount: 0, behindCount: 0, openPr: null, + mergedPr: null, }); expect(parsed.branch).toBe("feature/git-actions"); expect(parsed.hasWorkingTreeChanges).toBe(true); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 5b275df1c706..29fd8d8caa46 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -98,6 +98,15 @@ export const gitStatusResultSchema = z.object({ headBranch: z.string().min(1), }) .nullable(), + mergedPr: z + .object({ + number: z.number().int().positive(), + title: z.string().min(1), + url: z.string().url(), + baseBranch: z.string().min(1), + headBranch: z.string().min(1), + }) + .nullable(), }); export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]); From dcdb9ac92320d62028fd2d12be26167406666920 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 14:28:03 -0800 Subject: [PATCH 2/4] 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 --- apps/server/src/gitManager.test.ts | 22 +++++++++++++++++ apps/server/src/gitManager.ts | 11 ++++++--- apps/web/src/components/Sidebar.tsx | 38 ++++++++++++++++++++++------- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/apps/server/src/gitManager.test.ts b/apps/server/src/gitManager.test.ts index 961f74fb9de0..67aaaf978b5a 100644 --- a/apps/server/src/gitManager.test.ts +++ b/apps/server/src/gitManager.test.ts @@ -242,6 +242,28 @@ describe("GitManager", () => { expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true); }); + it("status skips open PR lookup for branches without upstream", async () => { + const repoDir = makeTempDir("t3code-git-manager-"); + await initRepo(repoDir); + await runGit(repoDir, ["checkout", "-b", "feature/local-only"]); + + const { runner, ghCalls } = createRunnerWithFakeGh({ + prListSequence: ["[]"], + }); + + const manager = new GitManager({ + runProcess: runner, + textGenerator: createTextGenerator(), + }); + + const status = await manager.status({ cwd: repoDir }); + expect(status.branch).toBe("feature/local-only"); + expect(status.openPr).toBeNull(); + expect(status.mergedPr).toBeNull(); + expect(ghCalls.some((call) => call.includes("--state open"))).toBe(false); + expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true); + }); + it("status is resilient to gh lookup failures and returns openPr/mergedPr null", async () => { const repoDir = makeTempDir("t3code-git-manager-"); await initRepo(repoDir); diff --git a/apps/server/src/gitManager.ts b/apps/server/src/gitManager.ts index d163b8ee9dd5..c8668d1cb8f1 100644 --- a/apps/server/src/gitManager.ts +++ b/apps/server/src/gitManager.ts @@ -195,10 +195,13 @@ export class GitManager { let mergedPr: ReturnType | null = null; if (details.branch) { try { - const existing = await this.findOpenPr(input.cwd, details.branch); - if (existing) { - openPr = toStatusOpenPr(existing); - } else { + if (details.hasUpstream) { + const existing = await this.findOpenPr(input.cwd, details.branch); + if (existing) { + openPr = toStatusOpenPr(existing); + } + } + if (!openPr) { const merged = await this.findMergedPr(input.cwd, details.branch); if (merged) { mergedPr = toStatusOpenPr(merged); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a0bd90740e91..5f18ccb42eee 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,7 +1,7 @@ import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; -import type { GitStatusResult } from "@t3tools/contracts"; +import { gitStatusResultSchema, type GitStatusResult } from "@t3tools/contracts"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; @@ -153,29 +153,49 @@ export default function Sidebar() { })), [projectCwdById, state.threads], ); + const threadGitStatusCwds = useMemo( + () => + [ + ...new Set( + threadGitTargets + .filter((target) => target.branch !== null) + .map((target) => target.cwd) + .filter((cwd): cwd is string => cwd !== null), + ), + ], + [threadGitTargets], + ); const threadGitStatusQueries = useQueries({ - queries: threadGitTargets.map((target) => { - const base = gitStatusQueryOptions(api, target.cwd); + queries: threadGitStatusCwds.map((cwd) => { + const base = gitStatusQueryOptions(api, cwd); return { ...base, - enabled: Boolean(base.enabled && target.branch !== null), + enabled: Boolean(base.enabled), staleTime: 30_000, refetchInterval: 60_000, }; }), }); const mergedPrByThreadId = useMemo(() => { + const statusByCwd = new Map(); + for (let index = 0; index < threadGitStatusCwds.length; index += 1) { + const cwd = threadGitStatusCwds[index]; + if (!cwd) continue; + const parsed = gitStatusResultSchema.safeParse(threadGitStatusQueries[index]?.data); + if (parsed.success) { + statusByCwd.set(cwd, parsed.data); + } + } + const map = new Map(); - for (let index = 0; index < threadGitTargets.length; index += 1) { - const target = threadGitTargets[index]; - if (!target) continue; - const status = threadGitStatusQueries[index]?.data as GitStatusResult | undefined; + for (const target of threadGitTargets) { + const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; const branchMatches = target.branch !== null && status?.branch !== null && status?.branch === target.branch; map.set(target.threadId, branchMatches && status?.openPr === null && status?.mergedPr !== null); } return map; - }, [threadGitStatusQueries, threadGitTargets]); + }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); const handleNewThread = useCallback( (projectId: string) => { From 973e62373e5b442ac9c13e0e65f771132c5e66d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 19:46:24 -0800 Subject: [PATCH 3/4] Unify git status PR info into a single `pr` field - Replace `openPr`/`mergedPr` with `pr` in server and contracts - Add PR `state` (`open`/`closed`/`merged`) to git status schema - Resolve latest branch PR via `gh pr list --state all` sorted by `updatedAt` --- apps/server/src/git.ts | 5 +- apps/server/src/gitManager.ts | 76 +++++++++++++++++++----------- apps/server/src/wsServer.test.ts | 6 +-- packages/contracts/src/git.test.ts | 3 +- packages/contracts/src/git.ts | 32 ++++++------- 5 files changed, 67 insertions(+), 55 deletions(-) diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts index cad9cad4c344..7c2da5ccf5a3 100644 --- a/apps/server/src/git.ts +++ b/apps/server/src/git.ts @@ -31,7 +31,7 @@ export interface TerminalCommandInput { export type TerminalCommandResult = ProcessRunResult; -export interface GitStatusDetails extends Omit { +export interface GitStatusDetails extends Omit { upstreamRef: string | null; } @@ -226,8 +226,7 @@ export class GitCoreService { hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, - openPr: null, - mergedPr: null, + pr: null, }); } diff --git a/apps/server/src/gitManager.ts b/apps/server/src/gitManager.ts index c8668d1cb8f1..d1ca7b0b57f4 100644 --- a/apps/server/src/gitManager.ts +++ b/apps/server/src/gitManager.ts @@ -7,6 +7,7 @@ import { gitRunStackedActionInputSchema, gitRunStackedActionResultSchema, gitStatusInputSchema, + type GitStatusPrState, type GitRunStackedActionInput, type GitRunStackedActionResult, type GitStatusInput, @@ -34,18 +35,20 @@ interface GitManagerDeps { gitCore?: GitCoreService; } -interface OpenPrInfo { +interface PullRequestInfo { number: number; title: string; url: string; baseRefName: string; headRefName: string; + state: GitStatusPrState; + updatedAt: string | null; } -function parseOpenPrList(raw: unknown): OpenPrInfo[] { +function parsePullRequestList(raw: unknown): PullRequestInfo[] { if (!Array.isArray(raw)) return []; - const parsed: OpenPrInfo[] = []; + const parsed: PullRequestInfo[] = []; for (const entry of raw) { if (!entry || typeof entry !== "object") continue; const record = entry as Record; @@ -54,6 +57,9 @@ function parseOpenPrList(raw: unknown): OpenPrInfo[] { const url = record.url; const baseRefName = record.baseRefName; const headRefName = record.headRefName; + const state = record.state; + const mergedAt = record.mergedAt; + const updatedAt = record.updatedAt; if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) { continue; } @@ -61,16 +67,34 @@ function parseOpenPrList(raw: unknown): OpenPrInfo[] { typeof title !== "string" || typeof url !== "string" || typeof baseRefName !== "string" || - typeof headRefName !== "string" + typeof headRefName !== "string" || + typeof state !== "string" ) { continue; } + + let normalizedState: GitStatusPrState; + if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { + normalizedState = "merged"; + } else if (state === "OPEN") { + normalizedState = "open"; + } else if (state === "CLOSED") { + normalizedState = "closed"; + } else { + continue; + } + + const normalizedUpdatedAt = + typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null; + parsed.push({ number, title, url, baseRefName, headRefName, + state: normalizedState, + updatedAt: normalizedUpdatedAt, }); } return parsed; @@ -160,12 +184,13 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined { return undefined; } -function toStatusOpenPr(pr: OpenPrInfo): { +function toStatusPr(pr: PullRequestInfo): { number: number; title: string; url: string; baseBranch: string; headBranch: string; + state: GitStatusPrState; } { return { number: pr.number, @@ -173,6 +198,7 @@ function toStatusOpenPr(pr: OpenPrInfo): { url: pr.url, baseBranch: pr.baseRefName, headBranch: pr.headRefName, + state: pr.state, }; } @@ -191,21 +217,12 @@ export class GitManager { const input = gitStatusInputSchema.parse(raw); const details = await this.gitCore.statusDetails(input.cwd); - let openPr: ReturnType | null = null; - let mergedPr: ReturnType | null = null; + let pr: ReturnType | null = null; if (details.branch) { try { - if (details.hasUpstream) { - const existing = await this.findOpenPr(input.cwd, details.branch); - if (existing) { - openPr = toStatusOpenPr(existing); - } - } - if (!openPr) { - const merged = await this.findMergedPr(input.cwd, details.branch); - if (merged) { - mergedPr = toStatusOpenPr(merged); - } + const existing = await this.findLatestPr(input.cwd, details.branch); + if (existing) { + pr = toStatusPr(existing); } } catch { // PR lookup is best-effort for status rendering. @@ -219,8 +236,7 @@ export class GitManager { hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, - openPr, - mergedPr, + pr, }; } @@ -390,7 +406,7 @@ export class GitManager { }; } - private async findOpenPr(cwd: string, branch: string): Promise { + private async findOpenPr(cwd: string, branch: string): Promise { const stdout = await this.runGhStdout(cwd, [ "pr", "list", @@ -401,7 +417,7 @@ export class GitManager { "--limit", "1", "--json", - "number,title,url,baseRefName,headRefName", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", ]); const raw = trimStdout(stdout); @@ -414,22 +430,22 @@ export class GitManager { throw new Error("GitHub CLI returned invalid PR list JSON."); } - const parsed = parseOpenPrList(parsedJson); + const parsed = parsePullRequestList(parsedJson); return parsed[0] ?? null; } - private async findMergedPr(cwd: string, branch: string): Promise { + private async findLatestPr(cwd: string, branch: string): Promise { const stdout = await this.runGhStdout(cwd, [ "pr", "list", "--head", branch, "--state", - "merged", + "all", "--limit", - "1", + "20", "--json", - "number,title,url,baseRefName,headRefName", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", ]); const raw = trimStdout(stdout); @@ -442,7 +458,11 @@ export class GitManager { throw new Error("GitHub CLI returned invalid PR list JSON."); } - const parsed = parseOpenPrList(parsedJson); + const parsed = parsePullRequestList(parsedJson).toSorted((a, b) => { + const left = a.updatedAt ? Date.parse(a.updatedAt) : 0; + const right = b.updatedAt ? Date.parse(b.updatedAt) : 0; + return right - left; + }); return parsed[0] ?? null; } diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 169a6b2bd4ec..d9bee192f7a5 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -563,8 +563,7 @@ describe("WebSocket Server", () => { hasUpstream: false, aheadCount: 0, behindCount: 0, - openPr: null, - mergedPr: null, + pr: null, }), runStackedAction: vi.fn(), }; @@ -593,8 +592,7 @@ describe("WebSocket Server", () => { hasUpstream: false, aheadCount: 0, behindCount: 0, - openPr: null, - mergedPr: null, + pr: null, }); expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" }); }); diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 05b467c35833..633702d432e7 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -36,8 +36,7 @@ describe("git contracts", () => { hasUpstream: false, aheadCount: 0, behindCount: 0, - openPr: null, - mergedPr: null, + pr: null, }); expect(parsed.branch).toBe("feature/git-actions"); expect(parsed.hasWorkingTreeChanges).toBe(true); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 29fd8d8caa46..b2a2416e4b52 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -72,6 +72,17 @@ export const gitPullInputSchema = z.object({ cwd: z.string().trim().min(1), }); +export const gitStatusPrStateSchema = z.enum(["open", "closed", "merged"]); + +export const gitStatusPrSchema = z.object({ + number: z.number().int().positive(), + title: z.string().min(1), + url: z.string().url(), + baseBranch: z.string().min(1), + headBranch: z.string().min(1), + state: gitStatusPrStateSchema, +}); + export const gitStatusResultSchema = z.object({ branch: z.string().min(1).nullable(), hasWorkingTreeChanges: z.boolean(), @@ -89,24 +100,7 @@ export const gitStatusResultSchema = z.object({ hasUpstream: z.boolean(), aheadCount: z.number().int().nonnegative(), behindCount: z.number().int().nonnegative(), - openPr: z - .object({ - number: z.number().int().positive(), - title: z.string().min(1), - url: z.string().url(), - baseBranch: z.string().min(1), - headBranch: z.string().min(1), - }) - .nullable(), - mergedPr: z - .object({ - number: z.number().int().positive(), - title: z.string().min(1), - url: z.string().url(), - baseBranch: z.string().min(1), - headBranch: z.string().min(1), - }) - .nullable(), + pr: gitStatusPrSchema.nullable(), }); export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]); @@ -146,6 +140,8 @@ export const gitRunStackedActionResultSchema = z.object({ export type GitStatusInput = z.input; export type GitPullInput = z.input; +export type GitStatusPrState = z.infer; +export type GitStatusPr = z.infer; export type GitStatusResult = z.infer; export type GitStackedAction = z.infer; export type GitRunStackedActionInput = z.input; From 7f2a95fe14c18b3feba039df6b88c6c47a846d04 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 11:10:18 -0800 Subject: [PATCH 4/4] Prefer open PR when resolving git status PR - Update GitManager PR selection to return the latest open PR before merged ones - Add regression test covering open-vs-merged ordering by updatedAt --- apps/server/src/git/Layers/GitManager.test.ts | 47 +++++++++++++++++++ apps/server/src/git/Layers/GitManager.ts | 5 ++ 2 files changed, 52 insertions(+) diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index b8d0ad1aab8a..88d26f771087 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -373,6 +373,53 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status prefers open PR when merged PR has newer updatedAt", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-over-merged"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 45, + title: "Merged PR", + url: "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/pingdotgg/codething-mvp/pull/45", + baseRefName: "main", + headRefName: "feature/status-open-over-merged", + state: "MERGED", + mergedAt: "2026-01-31T10:00:00Z", + updatedAt: "2026-02-01T10:00:00Z", + }, + { + number: 46, + title: "Open PR", + url: "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/pingdotgg/codething-mvp/pull/46", + baseRefName: "main", + headRefName: "feature/status-open-over-merged", + state: "OPEN", + updatedAt: "2026-01-30T10:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.branch).toBe("feature/status-open-over-merged"); + expect(status.pr).toEqual({ + number: 46, + title: "Open PR", + url: "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/pingdotgg/codething-mvp/pull/46", + baseBranch: "main", + headBranch: "feature/status-open-over-merged", + state: "open", + }); + }), + ); + it.effect("status is resilient to gh lookup failures and returns pr null", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 5deaf6ed4c40..27ad13af2767 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -223,6 +223,11 @@ export const makeGitManager = Effect.gen(function* () { const right = b.updatedAt ? Date.parse(b.updatedAt) : 0; return right - left; }); + + const latestOpenPr = parsed.find((pr) => pr.state === "open"); + if (latestOpenPr) { + return latestOpenPr; + } return parsed[0] ?? null; });