diff --git a/packages/shared/src/types/server-info.ts b/packages/shared/src/types/server-info.ts
index 852404d0cd..5c9ae793bb 100644
--- a/packages/shared/src/types/server-info.ts
+++ b/packages/shared/src/types/server-info.ts
@@ -18,6 +18,7 @@ export type ServerGitInfo =
available: true;
fullSha: string;
shortSha: string;
+ branchName: string | null;
subject: string;
committedAt: string | null;
localChanges: ServerGitLocalChanges;
diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts
index ab2eeb0e38..9fbc47f3b4 100644
--- a/server/src/__tests__/health.test.ts
+++ b/server/src/__tests__/health.test.ts
@@ -16,6 +16,7 @@ const testServerInfo = {
available: true,
fullSha: "0123456789abcdef0123456789abcdef01234567",
shortSha: "0123456",
+ branchName: "master",
subject: "Add server info debug view",
committedAt: "2026-06-25T23:00:00.000Z",
localChanges: {
diff --git a/server/src/__tests__/server-info.test.ts b/server/src/__tests__/server-info.test.ts
index a64b62bece..1af391d431 100644
--- a/server/src/__tests__/server-info.test.ts
+++ b/server/src/__tests__/server-info.test.ts
@@ -21,6 +21,7 @@ describe("server info snapshot", () => {
"Add server info debug view",
"2026-06-25T17:00:00-07:00",
].join("\n"),
+ gitBranchCommand: () => "feature/server-info\n",
gitStatusCommand: () => "",
});
@@ -30,6 +31,7 @@ describe("server info snapshot", () => {
available: true,
fullSha: "0123456789abcdef0123456789abcdef01234567",
shortSha: "0123456",
+ branchName: "feature/server-info",
subject: "Add server info debug view",
committedAt: "2026-06-26T00:00:00.000Z",
localChanges: {
@@ -98,6 +100,29 @@ describe("server info snapshot", () => {
});
});
+ it("keeps commit metadata available when HEAD is detached", () => {
+ 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"),
+ gitBranchCommand: () => {
+ throw new Error("detached HEAD");
+ },
+ gitStatusCommand: () => "",
+ });
+
+ expect(snapshot.git).toMatchObject({
+ available: true,
+ branchName: null,
+ shortSha: "0123456",
+ });
+ });
+
it("uses sanitized fallback metadata when git is unavailable", () => {
const snapshot = createServerInfoSnapshot({
now: new Date("2026-06-26T00:00:00.000Z"),
diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts
index 1c485cda35..1ad9b00cf1 100644
--- a/server/src/routes/openapi.ts
+++ b/server/src/routes/openapi.ts
@@ -930,6 +930,7 @@ registry.registerPath({
available: z.literal(true),
fullSha: z.string(),
shortSha: z.string(),
+ branchName: z.string().nullable(),
subject: z.string(),
committedAt: z.string().datetime().nullable(),
localChanges: z.union([
diff --git a/server/src/server-info.ts b/server/src/server-info.ts
index 369cb1435a..eb386be6c3 100644
--- a/server/src/server-info.ts
+++ b/server/src/server-info.ts
@@ -32,6 +32,18 @@ function defaultGitStatusCommand() {
);
}
+function defaultGitBranchCommand() {
+ return execFileSync(
+ "git",
+ ["symbolic-ref", "--quiet", "--short", "HEAD"],
+ {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ timeout: 1500,
+ },
+ );
+}
+
function parseGitLocalChanges(output: string): ServerGitLocalChanges {
let stagedFileCount = 0;
let unstagedFileCount = 0;
@@ -67,7 +79,11 @@ function getGitLocalChanges(gitStatusCommand: GitCommand): ServerGitLocalChanges
}
}
-function parseGitInfo(output: string, localChanges: ServerGitLocalChanges): ServerGitInfo {
+function parseGitInfo(
+ output: string,
+ branchName: string | null,
+ localChanges: ServerGitLocalChanges,
+): ServerGitInfo {
const [fullSha = "", shortSha = "", subject = "", committedAt = ""] = output
.trimEnd()
.split("\n");
@@ -81,6 +97,7 @@ function parseGitInfo(output: string, localChanges: ServerGitLocalChanges): Serv
available: true,
fullSha,
shortSha,
+ branchName,
subject: subject.trim() || "No commit subject",
committedAt: Number.isNaN(committedAtTime) ? null : new Date(committedAtTime).toISOString(),
localChanges,
@@ -90,22 +107,29 @@ function parseGitInfo(output: string, localChanges: ServerGitLocalChanges): Serv
function readGitInfo(
gitCommand: GitCommand = defaultGitCommand,
gitStatusCommand: GitCommand = defaultGitStatusCommand,
+ gitBranchCommand: GitCommand = defaultGitBranchCommand,
): ServerGitInfo {
try {
const output = gitCommand();
const localChanges = getGitLocalChanges(gitStatusCommand);
- return parseGitInfo(output, localChanges);
+ let branchName: string | null = null;
+ try {
+ branchName = gitBranchCommand().trim() || null;
+ } catch {
+ branchName = null;
+ }
+ return parseGitInfo(output, branchName, localChanges);
} catch {
return { available: false, unavailableReason: "git_unavailable" };
}
}
export function createServerInfoSnapshot(
- opts: { now?: Date; gitCommand?: GitCommand; gitStatusCommand?: GitCommand } = {},
+ opts: { now?: Date; gitCommand?: GitCommand; gitStatusCommand?: GitCommand; gitBranchCommand?: GitCommand } = {},
): ServerInfoSnapshot {
return {
processStartedAt: (opts.now ?? new Date()).toISOString(),
- git: readGitInfo(opts.gitCommand, opts.gitStatusCommand),
+ git: readGitInfo(opts.gitCommand, opts.gitStatusCommand, opts.gitBranchCommand),
};
}
@@ -119,12 +143,12 @@ const processStartedAt = new Date().toISOString();
let gitInfoCache: { value: ServerGitInfo; expiresAt: number } | null = null;
export function getServerInfoSnapshot(
- opts: { now?: number; gitCommand?: GitCommand; gitStatusCommand?: GitCommand } = {},
+ opts: { now?: number; gitCommand?: GitCommand; gitStatusCommand?: GitCommand; gitBranchCommand?: GitCommand } = {},
): ServerInfoSnapshot {
const now = opts.now ?? Date.now();
if (!gitInfoCache || now >= gitInfoCache.expiresAt) {
gitInfoCache = {
- value: readGitInfo(opts.gitCommand, opts.gitStatusCommand),
+ value: readGitInfo(opts.gitCommand, opts.gitStatusCommand, opts.gitBranchCommand),
expiresAt: now + GIT_INFO_CACHE_TTL_MS,
};
}
diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx
index c694b81fbf..14b6f221fd 100644
--- a/ui/src/components/Layout.tsx
+++ b/ui/src/components/Layout.tsx
@@ -549,6 +549,7 @@ export function Layout() {
{secondaryLabel}
- {version ? ( + {sourceSha && sourceFullSha ? ( ++ Paperclip{" "} + + {sourceSha.slice(0, 7)} + +
+Paperclip v{version}
) : null} diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx index 75da3d1cd2..a8340528fa 100644 --- a/ui/src/components/SidebarCompanyMenu.tsx +++ b/ui/src/components/SidebarCompanyMenu.tsx @@ -169,6 +169,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb setOpen(false); if (isMobile) setSidebarOpen(false); await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); + await queryClient.invalidateQueries({ queryKey: queryKeys.health }); }, }); diff --git a/ui/src/components/SidebarServerInfo.test.tsx b/ui/src/components/SidebarServerInfo.test.tsx index 5f4f8ef718..7b29ef1402 100644 --- a/ui/src/components/SidebarServerInfo.test.tsx +++ b/ui/src/components/SidebarServerInfo.test.tsx @@ -112,6 +112,7 @@ describe("SidebarServerInfo", () => { available: true, fullSha: "abcdef1234567890abcdef1234567890abcdef12", shortSha: "abcdef1", + branchName: "master", subject: "Add server info debug view", committedAt: "2026-06-25T23:00:00.000Z", localChanges: { @@ -160,6 +161,7 @@ describe("SidebarServerInfo", () => { available: true, fullSha: "abcdef1234567890abcdef1234567890abcdef12", shortSha: "abcdef1", + branchName: "master", subject: "Add server info debug view", committedAt: "2026-06-25T23:00:00.000Z", localChanges: { @@ -204,6 +206,7 @@ describe("SidebarServerInfo", () => { available: true, fullSha: "abcdef1234567890abcdef1234567890abcdef12", shortSha: "abcdef1", + branchName: "master", subject: "Add server info debug view", committedAt: "2026-06-25T23:00:00.000Z", localChanges: { @@ -233,6 +236,7 @@ describe("SidebarServerInfo", () => { available: true, fullSha: "1111111111111111111111111111111111111111", shortSha: "1111111", + branchName: "master", subject: "First boot", committedAt: "2026-06-25T23:00:00.000Z", localChanges: { @@ -279,6 +283,7 @@ describe("SidebarServerInfo", () => { available: true, fullSha: "2222222222222222222222222222222222222222", shortSha: "2222222", + branchName: "master", subject: "After restart", committedAt: "2026-06-26T01:30:00.000Z", localChanges: { diff --git a/ui/src/pages/Auth.test.tsx b/ui/src/pages/Auth.test.tsx index 47be067637..59d70be809 100644 --- a/ui/src/pages/Auth.test.tsx +++ b/ui/src/pages/Auth.test.tsx @@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { queryKeys } from "../lib/queryKeys"; import { AuthPage } from "./Auth"; const getSessionMock = vi.hoisted(() => vi.fn()); @@ -110,11 +111,11 @@ describe("AuthPage", () => { }); await flushReact(); await flushReact(); - return root; + return { root, queryClient }; } it("exposes password-manager metadata and a11y attributes on the sign-in form", async () => { - const root = await mount(); + const { root } = await mount(); const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement; const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement; @@ -147,7 +148,7 @@ describe("AuthPage", () => { }); it("uses new-password autocomplete in sign-up mode", async () => { - const root = await mount(); + const { root } = await mount(); const createOne = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "Create one", @@ -172,7 +173,7 @@ describe("AuthPage", () => { }); it("renders auth errors in an assertive alert region referenced by the inputs", async () => { - const root = await mount(); + const { root } = await mount(); const inputValueSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, @@ -213,4 +214,43 @@ describe("AuthPage", () => { root.unmount(); }); }); + + it("invalidates anonymous health metadata after sign-in", async () => { + const { root, queryClient } = await mount(); + queryClient.setQueryData(queryKeys.health, { + status: "ok", + deploymentMode: "authenticated", + }); + + const inputValueSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement; + const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement; + + await act(async () => { + inputValueSetter!.call(emailInput, "jane@example.com"); + emailInput.dispatchEvent(new Event("input", { bubbles: true })); + inputValueSetter!.call(passwordInput, "supersecret"); + passwordInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const form = container.querySelector("form") as HTMLFormElement; + await act(async () => { + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + await flushReact(); + await flushReact(); + + expect(signInEmailMock).toHaveBeenCalledWith({ + email: "jane@example.com", + password: "supersecret", + }); + expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(true); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/pages/Auth.tsx b/ui/src/pages/Auth.tsx index 20a8c542db..6948254782 100644 --- a/ui/src/pages/Auth.tsx +++ b/ui/src/pages/Auth.tsx @@ -53,6 +53,7 @@ export function AuthPage() { onSuccess: async () => { setError(null); await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); + await queryClient.invalidateQueries({ queryKey: queryKeys.health }); await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); navigate(nextPath, { replace: true }); }, diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx index c25d197069..3737e95c04 100644 --- a/ui/src/pages/InstanceGeneralSettings.tsx +++ b/ui/src/pages/InstanceGeneralSettings.tsx @@ -28,8 +28,9 @@ export function InstanceGeneralSettings() { const signOutMutation = useMutation({ mutationFn: () => authApi.signOut(), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); + await queryClient.invalidateQueries({ queryKey: queryKeys.health }); }, onError: (error) => { setActionError(error instanceof Error ? error.message : "Failed to sign out."); diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index 2c06666f0a..b540fc7528 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -374,6 +374,7 @@ export function InviteLandingPage() { setAuthFeedback(null); rememberPendingInviteToken(token); await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); + await queryClient.invalidateQueries({ queryKey: queryKeys.health }); await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess }); const { companies: freshCompanies } = await queryClient.fetchQuery(companiesListQueryOptions);