mirror of
https://github.com/cline/cline.git
synced 2026-09-06 20:41:02 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3b80b223d | |||
| d0e4f33a88 |
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user