Compare commits

...

2 Commits

Author SHA1 Message Date
abeatrix a3b80b223d feat(desktop): show git info as part of session metadata 2026-07-15 13:17:59 +08:00
abeatrix d0e4f33a88 feat(core): persist and refresh workspace git info 2026-07-15 12:24:56 +08:00
10 changed files with 379 additions and 24 deletions
@@ -58,6 +58,7 @@ export function discoverChatSessions(
prompt,
messages: session.messages,
});
const persistedMetadata = store.get(sessionId)?.metadata;
out.push({
sessionId,
status: session.status,
@@ -68,7 +69,10 @@ export function discoverChatSessions(
prompt,
startedAt: String(session.startedAt),
endedAt: session.endedAt ? String(session.endedAt) : undefined,
metadata: { title: resolvedTitle },
metadata: {
...(persistedMetadata ?? {}),
title: resolvedTitle,
},
});
}
@@ -525,6 +525,7 @@ function ThreadItem({
const infoItems: Array<[string, string | null | undefined]> = [
["ID", thread.id],
["Workspace", thread.codebase],
["Branch", thread.gitBranch],
["Status", thread.status],
["Updated", thread.time],
["Provider", thread.provider],
@@ -9,7 +9,10 @@ import type {
SessionHistoryStatus,
SessionMetadata,
} from "@/lib/session-history";
import { getSessionMetadataTitle } from "@/lib/session-history";
import {
getSessionMetadataGitBranch,
getSessionMetadataTitle,
} from "@/lib/session-history";
type CliDiscoveredSession = Omit<SessionHistoryItem, "status"> & {
status: string;
@@ -22,6 +25,7 @@ export interface SessionThread {
time: string;
provider: string;
model: string;
gitBranch?: string;
inputTokens?: number;
outputTokens?: number;
totalCostUsd?: number;
@@ -231,6 +235,7 @@ function toThread(session: SessionHistoryItem): SessionThread {
time: formatRelativeTime(session.endedAt || session.startedAt),
provider: session.provider || "",
model: session.model || "",
gitBranch: getSessionMetadataGitBranch(session.metadata) || undefined,
status: normalizeDiscoveredStatus(session.status, session.prompt),
};
}
@@ -328,6 +333,8 @@ function areSessionsEquivalent(
a.startedAt !== b.startedAt ||
a.endedAt !== b.endedAt ||
a.prompt !== b.prompt ||
getSessionMetadataGitBranch(a.metadata) !==
getSessionMetadataGitBranch(b.metadata) ||
getSessionMetadataTitle(a.metadata) !==
getSessionMetadataTitle(b.metadata) ||
a.workspaceRoot !== b.workspaceRoot ||
@@ -358,6 +365,7 @@ function areThreadsEquivalent(
a.time !== b.time ||
a.provider !== b.provider ||
a.model !== b.model ||
a.gitBranch !== b.gitBranch ||
a.inputTokens !== b.inputTokens ||
a.outputTokens !== b.outputTokens ||
a.totalCostUsd !== b.totalCostUsd ||
@@ -7,6 +7,10 @@ export type SessionHistoryStatus =
export type SessionMetadata = {
title?: string;
git?: {
url?: string;
branch?: string;
};
[key: string]: unknown;
};
@@ -31,3 +35,13 @@ export function getSessionMetadataTitle(metadata?: SessionMetadata): string {
}
return typeof metadata.title === "string" ? metadata.title.trim() : "";
}
export function getSessionMetadataGitBranch(
metadata?: SessionMetadata,
): string {
const git = metadata?.git;
if (!git || typeof git !== "object" || Array.isArray(git)) {
return "";
}
return typeof git.branch === "string" ? git.branch.trim() : "";
}
@@ -1,4 +1,10 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { MessageWithMetadata } from "@cline/llms";
@@ -11,10 +17,12 @@ import type {
BasicLogger,
} from "@cline/shared";
import { setClineDir, setHomeDir } from "@cline/shared/storage";
import simpleGit from "simple-git";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TelemetryService } from "../../services/telemetry/TelemetryService";
import { createSessionCompactionState } from "../../session/models/session-compaction";
import type { SessionManifest } from "../../session/models/session-manifest";
import { FileSessionService } from "../../session/services/file-session-service";
import { SessionSource } from "../../types/common";
import type { CoreSessionConfig } from "../../types/config";
import { LocalRuntimeHost as RuntimeHostUnderTest } from "./local-runtime-host";
@@ -174,6 +182,91 @@ describe("LocalRuntimeHost", () => {
rmSync(isolatedHomeDir, { recursive: true, force: true });
});
it("stores git under metadata and refreshes it after an active turn", async () => {
const workspaceRoot = join(isolatedHomeDir, "workspace");
mkdirSync(workspaceRoot, { recursive: true });
const git = simpleGit({ baseDir: workspaceRoot });
await git.init();
await git.addConfig("user.email", "test@example.com");
await git.addConfig("user.name", "Test");
await git.commit("initial", ["--allow-empty"]);
await git.addRemote("origin", "https://example.com/original.git");
const sessionsDir = join(isolatedHomeDir, "sessions");
const sessionId = "session-git-metadata";
const sessionService = new FileSessionService(sessionsDir);
const runtimeBuilder = {
build: vi.fn().mockReturnValue({
tools: [],
shutdown: vi.fn().mockResolvedValue(undefined),
}),
};
let initialManifest: SessionManifest | undefined;
const agent = {
run: vi.fn(async () => {
initialManifest = JSON.parse(
readFileSync(join(sessionsDir, sessionId, `${sessionId}.json`), "utf8"),
) as SessionManifest;
await git.checkoutLocalBranch("feature/session-git");
await git.removeRemote("origin");
await git.addRemote("origin", "https://example.com/updated.git");
return createResult();
}),
continue: vi.fn().mockResolvedValue(createResult()),
getMessages: vi.fn().mockReturnValue([]),
getAgentId: vi.fn().mockReturnValue("agent-root-git"),
getConversationId: vi.fn().mockReturnValue("conv-root-git"),
abort: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
canStartRun: vi.fn().mockReturnValue(true),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService,
runtimeBuilder: runtimeBuilder as never,
createAgent: () => agent as never,
});
const result = await manager.startSession(
normalizeStartInput({
config: createConfig({
sessionId,
cwd: workspaceRoot,
workspaceRoot,
enableAgentTeams: false,
}),
prompt: "change repository state",
interactive: true,
sessionMetadata: {
title: "Keep this title",
checkpoint: { latest: { ref: "checkpoint-ref" } },
},
}),
);
expect(initialManifest?.metadata).toMatchObject({
checkpoint: { latest: { ref: "checkpoint-ref" } },
git: {
url: "https://example.com/original.git",
branch: expect.any(String),
},
});
const manifest = JSON.parse(
readFileSync(result.manifestPath, "utf8"),
) as SessionManifest;
expect(manifest).not.toHaveProperty("git_url");
expect(manifest).not.toHaveProperty("git_branch");
expect(manifest.metadata).toMatchObject({
title: "change repository state",
checkpoint: { latest: { ref: "checkpoint-ref" } },
git: {
url: "https://example.com/updated.git",
branch: "feature/session-git",
},
});
});
it("emits session lifecycle telemetry when configured", async () => {
const sessionId = "sess-telemetry";
const manifest = createManifest(sessionId);
@@ -50,6 +50,12 @@ import {
sumUsageTotals,
} from "../../services/usage";
import { enrichPromptWithMentions } from "../../services/workspace";
import {
type GitWorkspaceState,
hasCurrentSessionGitMetadata,
readGitWorkspaceState,
withSessionGitMetadata,
} from "../../services/workspace/workspace-manifest";
import {
projectSessionCompactionState,
type SessionCompactionState,
@@ -475,6 +481,11 @@ export class LocalRuntimeHost implements RuntimeHost {
await this.persistSessionMetadata(sessionId, () => metadata);
},
});
const initialSessionMetadata = withSessionGitMetadata(
startInput.sessionMetadata ?? resumedArtifacts?.manifest.metadata,
bootstrap.gitState,
);
if (!resumedArtifacts) manifest.metadata = initialSessionMetadata;
const runtime = await this.runtimeBuilder.build(
bootstrap.runtimeBuilderInput,
);
@@ -691,7 +702,7 @@ export class LocalRuntimeHost implements RuntimeHost {
const active: ActiveSession = {
sessionId,
config: configWithProvider,
sessionMetadata: startInput.sessionMetadata,
sessionMetadata: initialSessionMetadata,
...(resumedArtifacts ? { artifacts: resumedArtifacts } : {}),
source,
startedAt: resumedArtifacts?.manifest.started_at ?? startedAt,
@@ -737,6 +748,9 @@ export class LocalRuntimeHost implements RuntimeHost {
active.compactionState = undefined;
}
this.sessions.set(sessionId, active);
if (resumedArtifacts) {
await this.refreshActiveSessionGitMetadata(active, bootstrap.gitState);
}
this.emitStatus(sessionId, "running");
if (initialMessages.length > 0 && !resumedArtifacts) {
await this.ensureSessionPersisted(active);
@@ -1425,27 +1439,32 @@ export class LocalRuntimeHost implements RuntimeHost {
session.pendingPrompt = prompt;
}
await this.ensureSessionPersisted(session);
await this.refreshActiveSessionGitMetadata(session);
await this.syncOAuthCredentials(session);
await this.markTurnRunning(session);
let result = await this.executeAgentTurn(
session,
prompt,
preparedInput.userImages,
preparedInput.userFiles,
);
while (shouldAutoContinueTeamRuns(session, result.finishReason)) {
const updates = await waitForTeamRunUpdates(session);
if (updates.length === 0) break;
const continuationPrompt = buildTeamRunContinuationPrompt(
try {
let result = await this.executeAgentTurn(
session,
updates,
prompt,
preparedInput.userImages,
preparedInput.userFiles,
);
result = await this.executeAgentTurn(session, continuationPrompt);
}
return result;
while (shouldAutoContinueTeamRuns(session, result.finishReason)) {
const updates = await waitForTeamRunUpdates(session);
if (updates.length === 0) break;
const continuationPrompt = buildTeamRunContinuationPrompt(
session,
updates,
);
result = await this.executeAgentTurn(session, continuationPrompt);
}
return result;
} finally {
await this.refreshActiveSessionGitMetadata(session);
}
}
private async completeInteractiveTurn(
@@ -1742,6 +1761,40 @@ export class LocalRuntimeHost implements RuntimeHost {
await this.updateStatus(session, "running", null);
}
private async refreshActiveSessionGitMetadata(
session: ActiveSession,
knownState?: GitWorkspaceState,
): Promise<void> {
try {
const state =
knownState ??
(await readGitWorkspaceState(resolveWorkspacePath(session.config)));
if (!state || !session.artifacts) return;
if (
hasCurrentSessionGitMetadata(
session.artifacts.manifest.metadata,
state,
)
) {
return;
}
await this.persistSessionMetadata(session.sessionId, (current) =>
withSessionGitMetadata(
{
...(current ?? {}),
...(session.sessionMetadata ?? {}),
},
state,
),
);
} catch (error) {
session.config.logger?.debug?.("Failed to refresh session git metadata", {
sessionId: session.sessionId,
error,
});
}
}
private async markTurnPending(session: ActiveSession): Promise<void> {
if (session.status === "pending") return;
await this.updateStatus(session, "pending", null);
@@ -1861,6 +1914,7 @@ export class LocalRuntimeHost implements RuntimeHost {
};
if (session.artifacts) {
await this.refreshActiveSessionGitMetadata(session);
try {
await this.updateStatus(session, input.status, input.exitCode);
} catch (error) {
@@ -55,6 +55,7 @@ import { hasRuntimeHooks, mergeAgentExtensions } from "./session-data";
import type { ProviderSettingsManager } from "./storage/provider-settings-manager";
import { InMemoryWorkspaceManager } from "./workspace/workspace-manager";
import { buildWorkspaceMetadataWithInfo } from "./workspace/workspace-manifest";
import type { GitWorkspaceState } from "./workspace/workspace-manifest";
import { emitWorkspaceLifecycleTelemetry } from "./workspace/workspace-telemetry";
function formatPluginFailure(failure: PluginInitializationFailure): string {
@@ -306,6 +307,7 @@ export interface LocalRuntimeBootstrap {
workspaceMetadata: string;
/** Structured git + path metadata generated alongside workspaceMetadata. */
workspaceInfo: WorkspaceInfo;
gitState: GitWorkspaceState;
extensions: AgentConfig["extensions"];
hooks: AgentHooks | undefined;
toolPolicies: AgentConfig["toolPolicies"];
@@ -351,8 +353,14 @@ export async function prepareLocalRuntimeBootstrap(
// Generate workspace + git metadata once, early, so it can be forwarded to
// hooks and extensions. The serialized string goes into CoreSessionConfig
// as workspaceMetadata; the structured object is kept as workspaceInfo.
const { workspaceInfo, workspaceMetadata, durationMs, vcsType, initError } =
await buildWorkspaceMetadataWithInfo(workspacePath);
const {
workspaceInfo,
workspaceMetadata,
gitState,
durationMs,
vcsType,
initError,
} = await buildWorkspaceMetadataWithInfo(workspacePath);
const configuredExtensionContext = localConfig?.extensionContext;
const extensionContext: ExtensionContext = {
...(configuredExtensionContext ?? {}),
@@ -500,6 +508,7 @@ export async function prepareLocalRuntimeBootstrap(
providerConfig,
workspaceMetadata,
workspaceInfo,
gitState,
extensions,
hooks,
toolPolicies,
@@ -3,7 +3,13 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import simpleGit from "simple-git";
import { afterEach, describe, expect, test } from "vitest";
import { generateWorkspaceInfoWithDiagnostics } from "./workspace-manifest";
import {
generateWorkspaceInfoWithDiagnostics,
hasCurrentSessionGitMetadata,
readGitWorkspaceState,
readSessionGitMetadata,
withSessionGitMetadata,
} from "./workspace-manifest";
const tempDirs: string[] = [];
@@ -19,6 +25,77 @@ afterEach(async () => {
);
});
describe("readGitWorkspaceState", () => {
test("prefers origin and returns the current branch", async () => {
const dir = await createTempDir();
const git = simpleGit({ baseDir: dir });
await git.init();
await git.addConfig("user.email", "test@example.com");
await git.addConfig("user.name", "Test");
await git.commit("initial", ["--allow-empty"]);
await git.addRemote("backup", "https://example.com/backup.git");
await git.addRemote("origin", "git@github.com:cline/cline.git");
await expect(readGitWorkspaceState(dir)).resolves.toEqual({
url: "git@github.com:cline/cline.git",
branch: (await git.branch()).current,
});
});
test("returns no fields outside a git repository", async () => {
await expect(readGitWorkspaceState(await createTempDir())).resolves.toEqual(
{},
);
});
});
describe("session git metadata", () => {
test("reads normalized git metadata", () => {
expect(
readSessionGitMetadata({
git: { url: " https://example.com/repo.git ", branch: " main " },
}),
).toEqual({
url: "https://example.com/repo.git",
branch: "main",
});
expect(readSessionGitMetadata({ git: "invalid" })).toEqual({});
});
test("merges git state without replacing sibling metadata", () => {
expect(
withSessionGitMetadata(
{
title: "Session title",
checkpoint: { latest: { ref: "abc" } },
git: { url: "old", commit: "preserved" },
},
{ url: "new", branch: "feature" },
),
).toEqual({
title: "Session title",
checkpoint: { latest: { ref: "abc" } },
git: { url: "new", branch: "feature", commit: "preserved" },
});
});
test("detects current state and removes git for non-git workspaces", () => {
const metadata = {
title: "Session title",
git: { url: "https://example.com/repo.git", branch: "main" },
};
expect(
hasCurrentSessionGitMetadata(metadata, {
url: "https://example.com/repo.git",
branch: "main",
}),
).toBe(true);
expect(withSessionGitMetadata(metadata, {})).toEqual({
title: "Session title",
});
});
});
describe("generateWorkspaceInfoWithDiagnostics", () => {
test("non-git directory reports vcsType none with no error", async () => {
const dir = await createTempDir();
@@ -7,6 +7,7 @@ import simpleGit from "simple-git";
export interface WorkspaceInfoDiagnostics {
info: WorkspaceInfo;
vcsType: "git" | "none";
gitState: GitWorkspaceState;
error?: { errorType: string; message: string };
}
@@ -15,9 +16,71 @@ export interface BuiltWorkspaceMetadata {
workspaceMetadata: string;
durationMs: number;
vcsType: "git" | "none";
gitState: GitWorkspaceState;
initError?: { errorType: string; message: string };
}
export interface GitWorkspaceState {
url?: string;
branch?: string;
}
export function readSessionGitMetadata(
metadata: Record<string, unknown> | undefined,
): GitWorkspaceState {
const git = metadata?.git;
if (!git || typeof git !== "object" || Array.isArray(git)) return {};
const record = git as Record<string, unknown>;
return {
...(typeof record.url === "string" && record.url.trim()
? { url: record.url.trim() }
: {}),
...(typeof record.branch === "string" && record.branch.trim()
? { branch: record.branch.trim() }
: {}),
};
}
export function withSessionGitMetadata(
metadata: Record<string, unknown> | undefined,
state: GitWorkspaceState,
): Record<string, unknown> | undefined {
const next = { ...(metadata ?? {}) };
if (!state.url && !state.branch) {
delete next.git;
return Object.keys(next).length > 0 ? next : undefined;
}
const existingGit =
next.git && typeof next.git === "object" && !Array.isArray(next.git)
? (next.git as Record<string, unknown>)
: {};
const git = { ...existingGit };
if (state.url) git.url = state.url;
else delete git.url;
if (state.branch) git.branch = state.branch;
else delete git.branch;
next.git = git;
return next;
}
export function hasCurrentSessionGitMetadata(
metadata: Record<string, unknown> | undefined,
state: GitWorkspaceState,
): boolean {
const current = readSessionGitMetadata(metadata);
return current.url === state.url && current.branch === state.branch;
}
function selectPrimaryGitRemoteUrl(
remotes: ReadonlyArray<{
name: string;
refs: { fetch: string; push: string };
}>,
): string | undefined {
const remote = remotes.find(({ name }) => name === "origin") ?? remotes[0];
return (remote?.refs.fetch || remote?.refs.push)?.trim() || undefined;
}
export function normalizeWorkspacePath(workspacePath: string): string {
return resolve(workspacePath);
}
@@ -64,13 +127,14 @@ export async function generateWorkspaceInfoWithDiagnostics(
rootPath,
hint: basename(rootPath),
};
const gitState: GitWorkspaceState = {};
let firstError: { errorType: string; message: string } | undefined;
try {
const git = simpleGit({ baseDir: rootPath });
const isRepo = await git.checkIsRepo();
if (!isRepo) {
return { info, vcsType: "none" };
return { info, vcsType: "none", gitState };
}
try {
@@ -81,6 +145,8 @@ export async function generateWorkspaceInfoWithDiagnostics(
return `${remote.name}: ${remoteUrl}`;
});
info.associatedRemoteUrls = associatedRemoteUrls;
const url = selectPrimaryGitRemoteUrl(remotes);
if (url) gitState.url = url;
}
} catch (error) {
firstError ??= toWorkspaceInfoError(error);
@@ -101,6 +167,7 @@ export async function generateWorkspaceInfoWithDiagnostics(
const latestGitBranchName = (await git.branch()).current.trim();
if (latestGitBranchName.length > 0) {
info.latestGitBranchName = latestGitBranchName;
gitState.branch = latestGitBranchName;
}
} catch (error) {
if (!isBenignGitError(error)) {
@@ -108,17 +175,40 @@ export async function generateWorkspaceInfoWithDiagnostics(
}
}
return { info, vcsType: "git", error: firstError };
return { info, vcsType: "git", gitState, error: firstError };
} catch (error) {
// Non-git workspaces keep only path + hint.
return {
info,
vcsType: "none",
gitState,
error: toWorkspaceInfoError(error),
};
}
}
/** Read the mutable git identity persisted with an active session. */
export async function readGitWorkspaceState(
workspacePath: string,
): Promise<GitWorkspaceState | undefined> {
try {
const git = simpleGit({ baseDir: normalizeWorkspacePath(workspacePath) });
if (!(await git.checkIsRepo())) return {};
const [remotes, branchSummary] = await Promise.all([
git.getRemotes(true),
git.branch(),
]);
const url = selectPrimaryGitRemoteUrl(remotes);
const branch = branchSummary.current.trim();
return {
...(url ? { url } : {}),
...(branch ? { branch } : {}),
};
} catch {
return undefined;
}
}
export async function buildWorkspaceMetadata(cwd: string): Promise<string> {
const workspaceInfo = await generateWorkspaceInfo(cwd);
return processWorkspaceInfo(workspaceInfo);
@@ -143,6 +233,7 @@ export async function buildWorkspaceMetadataWithInfo(
workspaceMetadata: processWorkspaceInfo(workspaceInfo),
durationMs,
vcsType: diagnostics.vcsType,
gitState: diagnostics.gitState,
initError: diagnostics.error,
};
}
+4
View File
@@ -22,6 +22,10 @@ export interface SessionRecord
export interface SessionHistoryMetadata extends Record<string, unknown> {
title?: string;
git?: {
url?: string;
branch?: string;
};
totalCost?: number;
aggregatedAgentsCost?: number;
usage?: SessionUsageMetadata;