diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index e1e8a5e58b..ddb7ade519 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -983,7 +983,7 @@ export type {
CloudUpstreamWarning,
} from "./types/cloud-upstream.js";
-export type { ServerGitInfo, ServerInfoSnapshot } from "./types/server-info.js";
+export type { ServerGitInfo, ServerGitLocalChanges, ServerInfoSnapshot } from "./types/server-info.js";
export {
getClosedIsolatedExecutionWorkspaceMessage,
diff --git a/packages/shared/src/types/server-info.ts b/packages/shared/src/types/server-info.ts
index add47f1fda..852404d0cd 100644
--- a/packages/shared/src/types/server-info.ts
+++ b/packages/shared/src/types/server-info.ts
@@ -1,5 +1,18 @@
// Shared between the server (which produces the snapshot at boot) and the UI
// (which renders it), so both sides stay in sync on a single definition.
+export type ServerGitLocalChanges =
+ | {
+ available: true;
+ hasLocalChanges: boolean;
+ stagedFileCount: number;
+ unstagedFileCount: number;
+ untrackedFileCount: number;
+ }
+ | {
+ available: false;
+ unavailableReason: "git_status_unavailable";
+ };
+
export type ServerGitInfo =
| {
available: true;
@@ -7,6 +20,7 @@ export type ServerGitInfo =
shortSha: string;
subject: string;
committedAt: string | null;
+ localChanges: ServerGitLocalChanges;
}
| {
available: false;
diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts
index 42b3705772..29abea1059 100644
--- a/server/src/__tests__/health.test.ts
+++ b/server/src/__tests__/health.test.ts
@@ -15,6 +15,13 @@ const testServerInfo = {
shortSha: "0123456",
subject: "Add server info debug view",
committedAt: "2026-06-25T23:00:00.000Z",
+ localChanges: {
+ available: true,
+ hasLocalChanges: false,
+ stagedFileCount: 0,
+ unstagedFileCount: 0,
+ untrackedFileCount: 0,
+ },
},
} as const;
diff --git a/server/src/__tests__/server-info.test.ts b/server/src/__tests__/server-info.test.ts
index 6cb3ec1d7c..a64b62bece 100644
--- a/server/src/__tests__/server-info.test.ts
+++ b/server/src/__tests__/server-info.test.ts
@@ -1,5 +1,14 @@
-import { describe, expect, it } from "vitest";
-import { createServerInfoSnapshot } from "../server-info.js";
+import { beforeEach, describe, expect, it } from "vitest";
+import {
+ createServerInfoSnapshot,
+ getServerInfoSnapshot,
+ resetServerInfoCacheForTests,
+} from "../server-info.js";
+
+function gitCommandFor(shortSha: string, subject: string): () => string {
+ return () =>
+ [shortSha.padEnd(40, "0"), shortSha, subject, "2026-06-25T17:00:00-07:00"].join("\n");
+}
describe("server info snapshot", () => {
it("captures process start time and git metadata", () => {
@@ -12,6 +21,7 @@ describe("server info snapshot", () => {
"Add server info debug view",
"2026-06-25T17:00:00-07:00",
].join("\n"),
+ gitStatusCommand: () => "",
});
expect(snapshot).toEqual({
@@ -22,6 +32,68 @@ describe("server info snapshot", () => {
shortSha: "0123456",
subject: "Add server info debug view",
committedAt: "2026-06-26T00:00:00.000Z",
+ localChanges: {
+ available: true,
+ hasLocalChanges: false,
+ stagedFileCount: 0,
+ unstagedFileCount: 0,
+ untrackedFileCount: 0,
+ },
+ },
+ });
+ });
+
+ it("summarizes local checkout changes without exposing file paths", () => {
+ const snapshot = createServerInfoSnapshot({
+ now: new Date("2026-06-26T00:00:00.000Z"),
+ gitCommand: () =>
+ [
+ "0123456789abcdef0123456789abcdef01234567",
+ "0123456",
+ "Add server info debug view",
+ "2026-06-25T17:00:00-07:00",
+ ].join("\n"),
+ gitStatusCommand: () =>
+ [
+ "M packages/shared/src/types/server-info.ts",
+ " M ui/src/components/SidebarServerInfo.tsx",
+ "MM server/src/server-info.ts",
+ "?? server/src/__tests__/server-info.test.ts",
+ ].join("\n"),
+ });
+
+ expect(snapshot.git).toMatchObject({
+ available: true,
+ localChanges: {
+ available: true,
+ hasLocalChanges: true,
+ stagedFileCount: 2,
+ unstagedFileCount: 2,
+ untrackedFileCount: 1,
+ },
+ });
+ });
+
+ it("keeps commit metadata available when git status is unavailable", () => {
+ const snapshot = createServerInfoSnapshot({
+ now: new Date("2026-06-26T00:00:00.000Z"),
+ gitCommand: () =>
+ [
+ "0123456789abcdef0123456789abcdef01234567",
+ "0123456",
+ "Add server info debug view",
+ "2026-06-25T17:00:00-07:00",
+ ].join("\n"),
+ gitStatusCommand: () => {
+ throw new Error("status unavailable");
+ },
+ });
+
+ expect(snapshot.git).toMatchObject({
+ available: true,
+ localChanges: {
+ available: false,
+ unavailableReason: "git_status_unavailable",
},
});
});
@@ -43,3 +115,37 @@ describe("server info snapshot", () => {
});
});
});
+
+describe("getServerInfoSnapshot", () => {
+ beforeEach(() => {
+ resetServerInfoCacheForTests();
+ });
+
+ it("re-reads the running commit after the cache TTL expires", () => {
+ const first = getServerInfoSnapshot({
+ now: 0,
+ gitCommand: gitCommandFor("aaaaaaa", "First boot"),
+ });
+ expect(first.git).toMatchObject({ shortSha: "aaaaaaa", subject: "First boot" });
+
+ // Within the TTL window the cached commit is reused.
+ const cached = getServerInfoSnapshot({
+ now: 1000,
+ gitCommand: gitCommandFor("bbbbbbb", "After restart"),
+ });
+ expect(cached.git).toMatchObject({ shortSha: "aaaaaaa", subject: "First boot" });
+
+ // Past the TTL the new HEAD is picked up without a process restart.
+ const refreshed = getServerInfoSnapshot({
+ now: 3000,
+ gitCommand: gitCommandFor("bbbbbbb", "After restart"),
+ });
+ expect(refreshed.git).toMatchObject({ shortSha: "bbbbbbb", subject: "After restart" });
+ });
+
+ it("keeps processStartedAt stable across refreshes", () => {
+ const first = getServerInfoSnapshot({ now: 0, gitCommand: gitCommandFor("aaaaaaa", "a") });
+ const second = getServerInfoSnapshot({ now: 5000, gitCommand: gitCommandFor("bbbbbbb", "b") });
+ expect(second.processStartedAt).toBe(first.processStartedAt);
+ });
+});
diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts
index 2fae791ca0..35b7f73af7 100644
--- a/server/src/routes/openapi.ts
+++ b/server/src/routes/openapi.ts
@@ -777,6 +777,19 @@ registry.registerPath({
shortSha: z.string(),
subject: z.string(),
committedAt: z.string().datetime().nullable(),
+ localChanges: z.union([
+ z.object({
+ available: z.literal(true),
+ hasLocalChanges: z.boolean(),
+ stagedFileCount: z.number().int().nonnegative(),
+ unstagedFileCount: z.number().int().nonnegative(),
+ untrackedFileCount: z.number().int().nonnegative(),
+ }).strict(),
+ z.object({
+ available: z.literal(false),
+ unavailableReason: z.enum(["git_status_unavailable"]),
+ }).strict(),
+ ]),
}).strict(),
z.object({
available: z.literal(false),
diff --git a/server/src/server-info.ts b/server/src/server-info.ts
index dcb0f9567c..369cb1435a 100644
--- a/server/src/server-info.ts
+++ b/server/src/server-info.ts
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
-import type { ServerGitInfo, ServerInfoSnapshot } from "@paperclipai/shared";
+import type { ServerGitInfo, ServerGitLocalChanges, ServerInfoSnapshot } from "@paperclipai/shared";
export type { ServerGitInfo, ServerInfoSnapshot };
@@ -20,7 +20,54 @@ function defaultGitCommand() {
);
}
-function parseGitInfo(output: string): ServerGitInfo {
+function defaultGitStatusCommand() {
+ return execFileSync(
+ "git",
+ ["status", "--porcelain=v1", "--untracked-files=normal"],
+ {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ timeout: 1500,
+ },
+ );
+}
+
+function parseGitLocalChanges(output: string): ServerGitLocalChanges {
+ let stagedFileCount = 0;
+ let unstagedFileCount = 0;
+ let untrackedFileCount = 0;
+
+ for (const line of output.split(/\r?\n/)) {
+ if (!line) continue;
+ const indexStatus = line[0] ?? " ";
+ const worktreeStatus = line[1] ?? " ";
+
+ if (indexStatus === "?" && worktreeStatus === "?") {
+ untrackedFileCount += 1;
+ continue;
+ }
+ if (indexStatus !== " " && indexStatus !== "?") stagedFileCount += 1;
+ if (worktreeStatus !== " " && worktreeStatus !== "?") unstagedFileCount += 1;
+ }
+
+ return {
+ available: true,
+ hasLocalChanges: stagedFileCount + unstagedFileCount + untrackedFileCount > 0,
+ stagedFileCount,
+ unstagedFileCount,
+ untrackedFileCount,
+ };
+}
+
+function getGitLocalChanges(gitStatusCommand: GitCommand): ServerGitLocalChanges {
+ try {
+ return parseGitLocalChanges(gitStatusCommand());
+ } catch {
+ return { available: false, unavailableReason: "git_status_unavailable" };
+ }
+}
+
+function parseGitInfo(output: string, localChanges: ServerGitLocalChanges): ServerGitInfo {
const [fullSha = "", shortSha = "", subject = "", committedAt = ""] = output
.trimEnd()
.split("\n");
@@ -36,27 +83,54 @@ function parseGitInfo(output: string): ServerGitInfo {
shortSha,
subject: subject.trim() || "No commit subject",
committedAt: Number.isNaN(committedAtTime) ? null : new Date(committedAtTime).toISOString(),
+ localChanges,
};
}
+function readGitInfo(
+ gitCommand: GitCommand = defaultGitCommand,
+ gitStatusCommand: GitCommand = defaultGitStatusCommand,
+): ServerGitInfo {
+ try {
+ const output = gitCommand();
+ const localChanges = getGitLocalChanges(gitStatusCommand);
+ return parseGitInfo(output, localChanges);
+ } catch {
+ return { available: false, unavailableReason: "git_unavailable" };
+ }
+}
+
export function createServerInfoSnapshot(
- opts: { now?: Date; gitCommand?: GitCommand } = {},
+ opts: { now?: Date; gitCommand?: GitCommand; gitStatusCommand?: GitCommand } = {},
): ServerInfoSnapshot {
- let git: ServerGitInfo;
- try {
- git = parseGitInfo((opts.gitCommand ?? defaultGitCommand)());
- } catch {
- git = { available: false, unavailableReason: "git_unavailable" };
- }
-
return {
processStartedAt: (opts.now ?? new Date()).toISOString(),
- git,
+ git: readGitInfo(opts.gitCommand, opts.gitStatusCommand),
};
}
-const serverInfoSnapshot = createServerInfoSnapshot();
+// processStartedAt is a true boot constant, but the running commit can change
+// without the Node process restarting: a managed dev-server restart re-runs the
+// code while keeping this module alive, so a commit captured once at boot goes
+// stale. Re-read git HEAD on demand, throttled by a short TTL so frequent health
+// polls don't spawn git on every request.
+const GIT_INFO_CACHE_TTL_MS = 3000;
+const processStartedAt = new Date().toISOString();
+let gitInfoCache: { value: ServerGitInfo; expiresAt: number } | null = null;
-export function getServerInfoSnapshot(): ServerInfoSnapshot {
- return serverInfoSnapshot;
+export function getServerInfoSnapshot(
+ opts: { now?: number; gitCommand?: GitCommand; gitStatusCommand?: GitCommand } = {},
+): ServerInfoSnapshot {
+ const now = opts.now ?? Date.now();
+ if (!gitInfoCache || now >= gitInfoCache.expiresAt) {
+ gitInfoCache = {
+ value: readGitInfo(opts.gitCommand, opts.gitStatusCommand),
+ expiresAt: now + GIT_INFO_CACHE_TTL_MS,
+ };
+ }
+ return { processStartedAt, git: gitInfoCache.value };
+}
+
+export function resetServerInfoCacheForTests(): void {
+ gitInfoCache = null;
}
diff --git a/ui/src/components/SidebarServerInfo.test.tsx b/ui/src/components/SidebarServerInfo.test.tsx
index 665c1fd45e..5f4f8ef718 100644
--- a/ui/src/components/SidebarServerInfo.test.tsx
+++ b/ui/src/components/SidebarServerInfo.test.tsx
@@ -29,6 +29,14 @@ async function flushReact() {
flushSync(() => {});
}
+async function flushReactMicrotasks() {
+ for (let index = 0; index < 6; index += 1) {
+ await Promise.resolve();
+ await vi.advanceTimersByTimeAsync(0);
+ }
+ flushSync(() => {});
+}
+
function mockEnabledSettings(enabled: boolean) {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableServerInfoDebugView: enabled,
@@ -69,6 +77,7 @@ describe("SidebarServerInfo", () => {
container.remove();
document.body.replaceChildren();
vi.clearAllMocks();
+ vi.useRealTimers();
});
it("renders nothing while the experimental flag is disabled", async () => {
@@ -105,6 +114,13 @@ describe("SidebarServerInfo", () => {
shortSha: "abcdef1",
subject: "Add server info debug view",
committedAt: "2026-06-25T23:00:00.000Z",
+ localChanges: {
+ available: true,
+ hasLocalChanges: false,
+ stagedFileCount: 0,
+ unstagedFileCount: 0,
+ untrackedFileCount: 0,
+ },
},
},
});
@@ -113,9 +129,174 @@ describe("SidebarServerInfo", () => {
expect(container.textContent).toContain("Last restarted");
expect(container.textContent).toContain("Running commit");
+ expect(container.textContent).toContain("Checkout state");
expect(container.querySelector('time[dateTime="2026-06-26T01:15:00.000Z"]')).not.toBeNull();
expect(container.textContent).toContain("abcdef1");
expect(container.textContent).toContain("Add server info debug view");
+ expect(container.textContent).toContain("Clean checkout");
+ });
+
+ it("polls health while the drawer is open and the dev server is active", async () => {
+ vi.useFakeTimers();
+ mockEnabledSettings(true);
+ mockHealthApi.get.mockResolvedValue({
+ status: "ok",
+ devServer: {
+ enabled: true,
+ restartRequired: false,
+ reason: null,
+ lastChangedAt: null,
+ changedPathCount: 0,
+ changedPathsSample: [],
+ pendingMigrations: [],
+ autoRestartEnabled: false,
+ activeRunCount: 0,
+ waitingForIdle: false,
+ lastRestartAt: "2026-06-26T01:15:00.000Z",
+ },
+ serverInfo: {
+ processStartedAt: "2026-06-26T00:00:00.000Z",
+ git: {
+ available: true,
+ fullSha: "abcdef1234567890abcdef1234567890abcdef12",
+ shortSha: "abcdef1",
+ subject: "Add server info debug view",
+ committedAt: "2026-06-25T23:00:00.000Z",
+ localChanges: {
+ available: true,
+ hasLocalChanges: false,
+ stagedFileCount: 0,
+ unstagedFileCount: 0,
+ untrackedFileCount: 0,
+ },
+ },
+ },
+ });
+
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ root = createRoot(container);
+ flushSync(() => {
+ root!.render(
+