diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts index 1fbd86c7bee1a07cdeea77b3101fa332ac136a91..cad9cad4c344f081b3f8cd8be5bba4f2e034e486 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 f2d399c8b8762a023be50147826264d77a0f1910..961f74fb9de082485d83fc712b57785f8fb161c3 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 is resilient to gh lookup failures and returns openPr null", async () => { + 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: "https://github.com/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: "https://github.com/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/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 a9d7689513a0fe0c8fac10763e9374bfa1dda7d1..d163b8ee9dd5f37cfd29aac77bf24d1c114fa15e 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 baf70eaca2b367a99d7f2b3c218578f3ba1650c2..169a6b2bd4ec8ea3e33aeb37bdff90d87eacb948 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 b764fac276bca59c6cd29fc2979df70b611d3bf7..feac3a689bd2ee9ab9b229a65f1d53a66f80b1af 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 e851468280cf090ff252b2633d664425556fd190..a0bd90740e910ca052183216c6e5c600ffccc12b 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 596f9c8aa96b099ee9a7abc1b23e2ac91e0dc644..05b467c35833e4335b6a13a6566f88a48714383d 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 5b275df1c706f38b2e185b2349d2f3f48779c967..29fd8d8caa466aeda9750b5ad52aae997a101019 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"]);