Files
openchamber/packages/ui/src/lib/gitApi.ts
T
Serhii DziupinandSerhii Dziupin 86e6a2ae76 Remove verified dead declarations (#2714)
* chore: remove verified dead declarations

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: narrow unused internal exports

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: remove newly exposed dead helpers

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: remove unused deep-link serializer

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: drop two tests that assert on copies of the code

mainLayoutMobileSidebarMount read MainLayout.tsx and SessionSidebar.tsx as
strings and asserted on source substrings down to exact indentation, so it
failed on formatting rather than behaviour. useProjectSessionSelection.test
reimplemented the hook's visitNodes logic inside the test file and asserted
against that copy, so it could not observe the hook at all.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: repair sync suites that had rotted while unrunnable

No runner executed packages/ui, so these drifted from the source unnoticed:
two imported helpers that are no longer exported, one directory-store stub
predated the session field routeMessage reads, and the WebSocket fake missed
the mandatory url-token mint plus the close event the socket wrapper reads.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: stop the web suite failing on timeouts and a hand-copied mock

The Git suites drive a real git binary, so the 5s default made a valid suite
fail differently per run. The gitApiHttp mock listed ~70 export names by hand
and fell behind the source; it now derives every stub from the real module,
which the added shared-UI aliases make resolvable.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: run every suite from one command and in CI

packages/ui (232 files) and packages/vscode (22) had no test script at all, CI
ran neither, and 9 vscode files could never run because Node cannot resolve
their extensionless TypeScript imports. Three electron files sat outside every
script list, one of them importing vitest, which that package does not depend
on. A runner gives each file its own process, since these suites keep
module-level singletons and fail by load order when sharing one.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: delete a superseded repro harness and a completed plan

The issue-2638 harness needed lsof, overrode process.platform and spawned real
servers, and nothing referenced it; event-stream/rebind.test.js now covers the
same hub-pinned-to-the-old-port behaviour. The pairing v2 plan described relay
and the pairing UI as out of scope, both of which shipped.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* docs: point at the theme tools and record the github barrel invariant

convert-vscode-theme and harmonize-theme were referenced nowhere, so the
theme-authoring reference now names them. The github barrel is loaded through
await import('./index.js') and destructured per route, which no static report
can see; documenting that is what stops the next cleanup from deleting it.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: repair merge drift in bridge and route-registry mocks

upstream/main gained upsertProviderConfig on bridge-system-runtime and a
PATCH scheduled-task route after this branch forked. Their test doubles
were never updated to match:
- bridge-system-runtime.test.js: add upsertProviderConfig to the
  opencodeConfig mock so the import resolves.
- sse-routes.test.js: add app.patch to the route registry stub.

---------

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
2026-08-13 15:30:54 +03:00

1081 lines
39 KiB
TypeScript

import * as gitHttp from './gitApiHttp';
import { opencodeClient } from './opencode/client';
import { renderMagicPrompt } from './magicPrompts';
import { runtimeFetch } from './runtime-fetch';
import { materializeOpenDraftSession, useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useConfigStore } from '@/stores/useConfigStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
export type {
GitRemote,
MergeConflictDetails,
CommitFileDiffResponse,
} from './api/types';
const getRuntimeGit = () => {
return getRegisteredRuntimeAPIs()?.git ?? null;
};
const requestChatForceScrollBottom = (sessionId: string) => {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent('openchamber:chat-force-scroll-bottom', {
detail: { sessionId },
}));
};
const extractJsonObject = (value: string): Record<string, unknown> | null => {
const text = value.trim();
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
const candidate = (fenced?.[1] ?? text).trim();
const starts = [candidate.indexOf('{')].filter((index) => index >= 0);
for (const start of starts) {
for (let end = candidate.length; end > start; end -= 1) {
if (candidate[end - 1] !== '}') continue;
try {
const parsed = JSON.parse(candidate.slice(start, end)) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// Keep scanning; models sometimes wrap JSON with prose or fences.
}
}
}
return null;
};
const extractAssistantText = (response: unknown): string => {
const data = (response as { data?: { parts?: Array<unknown> } } | null)?.data;
const parts = Array.isArray(data?.parts) ? data.parts : [];
return parts
.map((part) => {
const item = part as { type?: unknown; text?: unknown; content?: unknown; value?: unknown };
if (item.type !== 'text') return '';
if (typeof item.text === 'string') return item.text;
if (typeof item.content === 'string') return item.content;
if (typeof item.value === 'string') return item.value;
return '';
})
.filter((text) => text.trim().length > 0)
.join('\n')
.trim();
};
export async function checkIsGitRepository(directory: string): Promise<boolean> {
const runtime = getRuntimeGit();
if (runtime) return runtime.checkIsGitRepository(directory);
return gitHttp.checkIsGitRepository(directory);
}
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<import('./api/types').GitStatus> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getGitStatus(directory, options);
return gitHttp.getGitStatus(directory, options);
}
export async function resolveGitPrimaryRoot(directory: string): Promise<string> {
const result = await gitHttp.resolveGitPrimaryRoot(directory);
return result.root;
}
export async function resolveGitTopLevel(directory: string): Promise<string> {
const result = await gitHttp.resolveGitTopLevel(directory);
return result.root;
}
export async function getGitCommitSummaries(
directory: string,
shas: string[]
): Promise<Array<{ sha: string; short: string; subject: string }>> {
const result = await gitHttp.getGitCommitSummaries(directory, shas);
return result.commits;
}
export async function getGitDiff(directory: string, options: import('./api/types').GetGitDiffOptions): Promise<import('./api/types').GitDiffResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getGitDiff(directory, options);
return gitHttp.getGitDiff(directory, options);
}
export async function getGitFileDiff(
directory: string,
options: import('./api/types').GetGitFileDiffOptions
): Promise<import('./api/types').GitFileDiffResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getGitFileDiff(directory, options);
return gitHttp.getGitFileDiff(directory, options);
}
export async function getGitRangeDiff(
directory: string,
options: import('./api/types').GetGitRangeDiffOptions
): Promise<import('./api/types').GitDiffResponse> {
const runtime = getRuntimeGit();
if (runtime?.getGitRangeDiff) return runtime.getGitRangeDiff(directory, options);
return gitHttp.getGitRangeDiff(directory, options);
}
export async function revertGitFile(
directory: string,
filePath: string,
options?: { scope?: 'all' | 'working' }
): Promise<void> {
const runtime = getRuntimeGit();
if (runtime) return runtime.revertGitFile(directory, filePath, options);
return gitHttp.revertGitFile(directory, filePath, options);
}
export async function stageGitFile(directory: string, filePath: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.stageGitFile) return runtime.stageGitFile(directory, filePath);
return gitHttp.stageGitFile(directory, filePath);
}
export async function stageGitFiles(directory: string, filePaths: string[]): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.stageGitFiles) return runtime.stageGitFiles(directory, filePaths);
return gitHttp.stageGitFiles(directory, filePaths);
}
export async function unstageGitFile(directory: string, filePath: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.unstageGitFile) return runtime.unstageGitFile(directory, filePath);
return gitHttp.unstageGitFile(directory, filePath);
}
export async function unstageGitFiles(directory: string, filePaths: string[]): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.unstageGitFiles) return runtime.unstageGitFiles(directory, filePaths);
return gitHttp.unstageGitFiles(directory, filePaths);
}
export async function stageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.stageGitHunk) return runtime.stageGitHunk(directory, filePath, patch);
return gitHttp.stageGitHunk(directory, filePath, patch);
}
export async function unstageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.unstageGitHunk) return runtime.unstageGitHunk(directory, filePath, patch);
return gitHttp.unstageGitHunk(directory, filePath, patch);
}
export async function revertGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.revertGitHunk) return runtime.revertGitHunk(directory, filePath, patch);
return gitHttp.revertGitHunk(directory, filePath, patch);
}
export async function isLinkedWorktree(directory: string): Promise<boolean> {
const runtime = getRuntimeGit();
if (runtime) return runtime.isLinkedWorktree(directory);
return gitHttp.isLinkedWorktree(directory);
}
export async function getGitBranches(directory: string): Promise<import('./api/types').GitBranch> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getGitBranches(directory);
return gitHttp.getGitBranches(directory);
}
export async function deleteGitBranch(directory: string, payload: import('./api/types').GitDeleteBranchPayload): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.deleteGitBranch(directory, payload);
return gitHttp.deleteGitBranch(directory, payload);
}
export async function deleteRemoteBranch(directory: string, payload: import('./api/types').GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.deleteRemoteBranch(directory, payload);
return gitHttp.deleteRemoteBranch(directory, payload);
}
const COMMIT_DIFF_FILE_LIMIT = 30;
const COMMIT_DIFF_TOTAL_CHAR_LIMIT = 120_000;
const collectSelectedFileDiffs = async (directory: string, files: string[]): Promise<string> => {
const limited = files.slice(0, COMMIT_DIFF_FILE_LIMIT);
const chunks = await Promise.all(limited.map(async (path) => {
try {
const [staged, unstaged] = await Promise.all([
gitHttp.getGitDiff(directory, { path, staged: true }).catch(() => null),
gitHttp.getGitDiff(directory, { path, staged: false }).catch(() => null),
]);
const text = [staged?.diff, unstaged?.diff]
.filter((diff): diff is string => typeof diff === 'string' && diff.trim().length > 0)
.join('\n');
return text ? text : `--- ${path} (no textual diff available)`;
} catch {
return `--- ${path} (diff unavailable)`;
}
}));
let total = '';
for (const chunk of chunks) {
if (total.length + chunk.length > COMMIT_DIFF_TOTAL_CHAR_LIMIT) {
total += '\n[remaining diffs truncated]';
break;
}
total += (total ? '\n\n' : '') + chunk;
}
if (files.length > limited.length) {
total += `\n[${files.length - limited.length} more selected files omitted]`;
}
return total;
};
const parseCommitStructured = (structured: Record<string, unknown> | null): { subject: string; highlights: string[] } => {
const subject = typeof structured?.subject === 'string' ? structured.subject.trim() : '';
const highlights = Array.isArray(structured?.highlights)
? structured.highlights.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean).slice(0, 3)
: [];
if (!subject) {
throw new Error('Structured output missing subject');
}
return { subject, highlights };
};
// Legacy transport: run the structured generation inside the active chat
// session. Kept as the fallback for setups with no direct provider login
// (vanilla installs on OpenCode's free models), where the small-model
// endpoint has nothing to call but the session itself still works.
async function generateCommitMessageViaSession(
directory: string,
visiblePrompt: string,
hiddenPrompt: string,
): Promise<{ message: import('./api/types').GeneratedCommitMessage }> {
const generationSession = await resolveGenerationSessionContext();
const structured = await runStructuredGenerationInActiveSession({
directory,
visiblePrompt,
hiddenPrompt,
generationSession,
kind: 'commit',
});
return { message: parseCommitStructured(structured) };
}
export async function generateCommitMessage(
directory: string,
files: string[],
options?: { zenModel?: string; providerId?: string; modelId?: string }
): Promise<{ message: import('./api/types').GeneratedCommitMessage }> {
const startedAt = Date.now();
void options;
console.info('[git-generation][browser] request', {
transport: 'small-model',
kind: 'commit',
directory,
selectedFiles: files.length,
});
const visiblePrompt = await renderMagicPrompt('git.commit.generate.visible');
const hiddenPrompt = await renderMagicPrompt('git.commit.generate.instructions', {
selected_files: files.map((file) => `- ${file}`).join('\n'),
});
try {
const diffs = await collectSelectedFileDiffs(directory, files);
const { currentProviderId, currentModelId } = useConfigStore.getState();
const response = await runtimeFetch('/api/small-model/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system: visiblePrompt,
prompt: `${hiddenPrompt}\n\nDiffs of the selected files:\n${diffs}`,
directory,
...(currentProviderId ? { preferredProviderID: currentProviderId } : {}),
...(currentModelId ? { preferredModelID: currentModelId } : {}),
}),
});
if (response.status === 404) {
// No authenticated provider has a small model — fall back to the
// session transport so free-model-only setups keep a working button.
console.info('[git-generation][browser] small model unavailable, falling back to session transport');
const result = await generateCommitMessageViaSession(directory, visiblePrompt, hiddenPrompt);
console.info('[git-generation][browser] success', {
transport: 'session-fallback',
kind: 'commit',
elapsedMs: Date.now() - startedAt,
subjectLength: result.message.subject.length,
highlightsCount: result.message.highlights.length,
});
return result;
}
const payload = await response.json().catch(() => null) as { text?: unknown; error?: unknown } | null;
if (!response.ok || typeof payload?.text !== 'string') {
const message = typeof payload?.error === 'string' ? payload.error : `HTTP ${response.status}`;
throw new Error(message);
}
const result = { message: parseCommitStructured(extractJsonObject(payload.text)) };
console.info('[git-generation][browser] success', {
transport: 'small-model',
kind: 'commit',
elapsedMs: Date.now() - startedAt,
subjectLength: result.message.subject.length,
highlightsCount: result.message.highlights.length,
});
return result;
} catch (error) {
console.error('[git-generation][browser] failed', {
transport: 'small-model',
kind: 'commit',
elapsedMs: Date.now() - startedAt,
message: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
}
export async function generatePullRequestDescription(
directory: string,
payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
): Promise<import('./api/types').GeneratedPullRequestDescription> {
const startedAt = Date.now();
const commitLog = await getGitLog(directory, {
from: payload.base,
to: payload.head,
maxCount: 50,
});
const COMMIT_BODY_CHAR_LIMIT = 2_000;
const commits = (Array.isArray(commitLog?.all) ? commitLog.all : [])
.filter((entry) => typeof entry?.hash === 'string' && entry.hash.length > 0)
.map((entry) => ({
hash: entry.hash,
subject: typeof entry.message === 'string' ? entry.message.trim() : '',
body: typeof entry.body === 'string' ? entry.body.trim().slice(0, COMMIT_BODY_CHAR_LIMIT) : '',
}));
if (commits.length === 0) {
throw new Error(`No commits found in range ${payload.base}...${payload.head}`);
}
const filesSet = new Set<string>();
await Promise.all(commits.map(async (commit) => {
try {
const response = await getCommitFiles(directory, commit.hash);
const files = Array.isArray(response?.files) ? response.files : [];
for (const file of files) {
if (typeof file?.path === 'string' && file.path.trim().length > 0) {
filesSet.add(file.path.trim());
}
}
} catch (error) {
console.warn('[git-generation][browser] failed to collect commit files', {
hash: commit.hash,
error: error instanceof Error ? error.message : String(error),
});
}
}));
const changedFiles = Array.from(filesSet).sort().slice(0, 300);
console.info('[git-generation][browser] request', {
transport: 'small-model',
kind: 'pr',
directory,
base: payload.base,
head: payload.head,
commits: commits.length,
changedFiles: changedFiles.length,
});
const visiblePrompt = await renderMagicPrompt('git.pr.generate.visible');
const hiddenPrompt = await renderMagicPrompt('git.pr.generate.instructions', {
base_branch: payload.base,
head_branch: payload.head,
commits: commits.map((commit) => {
const line = `- ${commit.hash.slice(0, 7)} ${commit.subject || '(no subject)'}`;
if (!commit.body) return line;
const indentedBody = commit.body.split('\n').map((bodyLine) => ` ${bodyLine}`).join('\n');
return `${line}\n${indentedBody}`;
}).join('\n'),
changed_files: changedFiles.length > 0 ? changedFiles.map((file) => `- ${file}`).join('\n') : '- none detected',
additional_context_block: payload.context?.trim() ? `\nAdditional context:\n${payload.context.trim()}` : '',
});
const parsePrStructured = (structured: Record<string, unknown> | null) => ({
title: typeof structured?.title === 'string' ? structured.title.trim() : '',
body: typeof structured?.body === 'string' ? structured.body.trim() : '',
});
try {
const { currentProviderId, currentModelId } = useConfigStore.getState();
const response = await runtimeFetch('/api/small-model/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system: visiblePrompt,
prompt: hiddenPrompt,
directory,
...(currentProviderId ? { preferredProviderID: currentProviderId } : {}),
...(currentModelId ? { preferredModelID: currentModelId } : {}),
}),
});
if (response.status === 404) {
// No authenticated provider has a small model — fall back to the
// session transport so free-model-only setups keep working.
console.info('[git-generation][browser] small model unavailable, falling back to session transport');
const generationSession = await resolveGenerationSessionContext();
const structured = await runStructuredGenerationInActiveSession({
directory,
visiblePrompt,
hiddenPrompt,
generationSession,
kind: 'pr',
});
const result = parsePrStructured(structured);
console.info('[git-generation][browser] success', {
transport: 'session-fallback',
kind: 'pr',
elapsedMs: Date.now() - startedAt,
titleLength: result.title.length,
bodyLength: result.body.length,
});
return result;
}
const payload = await response.json().catch(() => null) as { text?: unknown; error?: unknown } | null;
if (!response.ok || typeof payload?.text !== 'string') {
const message = typeof payload?.error === 'string' ? payload.error : `HTTP ${response.status}`;
throw new Error(message);
}
const result = parsePrStructured(extractJsonObject(payload.text));
console.info('[git-generation][browser] success', {
transport: 'small-model',
kind: 'pr',
elapsedMs: Date.now() - startedAt,
titleLength: result.title.length,
bodyLength: result.body.length,
});
return result;
} catch (error) {
console.error('[git-generation][browser] failed', {
transport: 'small-model',
kind: 'pr',
elapsedMs: Date.now() - startedAt,
message: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
}
type SessionGenerationContext = {
sessionId: string;
providerID: string;
modelID: string;
agent?: string;
variant?: string;
};
const GENERATION_CONFIG_ERROR = 'No default provider or model configured. Please select a provider and model in settings first.';
async function resolveGenerationSessionContext(): Promise<SessionGenerationContext> {
const activeSession = resolveSessionGenerationContext();
if (activeSession) {
return activeSession;
}
const draft = useSessionUIStore.getState().newSessionDraft;
if (!draft?.open) {
throw new Error('Select existing session for generation');
}
const config = useConfigStore.getState();
if (!config.currentProviderId || !config.currentModelId) {
throw new Error(GENERATION_CONFIG_ERROR);
}
const createdDraftSession = await materializeOpenDraftSession({
providerID: config.currentProviderId,
modelID: config.currentModelId,
agent: config.currentAgentName || undefined,
variant: config.currentVariant || undefined,
});
if (!createdDraftSession) {
const retry = resolveSessionGenerationContext();
if (retry) {
return retry;
}
throw new Error('Failed to create session for generation');
}
return {
sessionId: createdDraftSession.sessionId,
providerID: config.currentProviderId,
modelID: config.currentModelId,
agent: createdDraftSession.agent,
variant: config.currentVariant || undefined,
};
}
const resolveSessionGenerationContext = (): SessionGenerationContext | null => {
const sessionId = useSessionUIStore.getState().currentSessionId;
if (!sessionId) {
return null;
}
const selection = useSelectionStore.getState();
const config = useConfigStore.getState();
const lastChoice = useSessionUIStore.getState().getLastUserChoice(sessionId);
const agent = selection.getSessionAgentSelection(sessionId) || lastChoice?.agent || config.currentAgentName || undefined;
const sessionModel = selection.getSessionModelSelection(sessionId);
const agentModel = agent ? selection.getAgentModelForSession(sessionId, agent) : null;
const lastChoiceModel = lastChoice?.providerID && lastChoice.modelID
? { providerId: lastChoice.providerID, modelId: lastChoice.modelID }
: null;
const selectedModel = agentModel || sessionModel || lastChoiceModel || (config.currentProviderId && config.currentModelId
? { providerId: config.currentProviderId, modelId: config.currentModelId }
: null);
if (!selectedModel?.providerId || !selectedModel?.modelId) {
return null;
}
const selectionVariant = agent
? selection.getAgentModelVariantForSession(sessionId, agent, selectedModel.providerId, selectedModel.modelId)
: undefined;
const lastChoiceVariant = lastChoiceModel
&& lastChoiceModel.providerId === selectedModel.providerId
&& lastChoiceModel.modelId === selectedModel.modelId
? lastChoice?.variant
: undefined;
const configVariant = config.currentProviderId === selectedModel.providerId && config.currentModelId === selectedModel.modelId
? config.currentVariant
: undefined;
const variant = selectionVariant || lastChoiceVariant || configVariant || undefined;
return {
sessionId,
providerID: selectedModel.providerId,
modelID: selectedModel.modelId,
agent,
variant,
};
};
const runStructuredGenerationInActiveSession = async ({
directory,
visiblePrompt,
hiddenPrompt,
generationSession,
kind,
}: {
directory: string;
visiblePrompt: string;
hiddenPrompt?: string;
generationSession: SessionGenerationContext;
kind: 'commit' | 'pr';
}): Promise<Record<string, unknown>> => {
const requestStartedAt = Date.now();
console.info('[git-generation][browser] runStructuredGenerationInActiveSession start', {
kind,
directory,
sessionId: generationSession.sessionId,
providerID: generationSession.providerID,
modelID: generationSession.modelID,
agent: generationSession.agent,
variant: generationSession.variant,
});
const trimmedDirectory = typeof directory === 'string' ? directory.trim() : '';
const visiblePromptText = typeof visiblePrompt === 'string' ? visiblePrompt.trim() : '';
const hiddenPromptText = typeof hiddenPrompt === 'string' ? hiddenPrompt.trim() : '';
const promptParts: Array<{ type: 'text'; text: string; synthetic?: boolean }> = [];
if (visiblePromptText) {
promptParts.push({
type: 'text',
text: hiddenPromptText ? `${visiblePromptText}\n\n` : visiblePromptText,
synthetic: false,
});
}
if (hiddenPromptText) {
promptParts.push({ type: 'text', text: hiddenPromptText, synthetic: true });
}
if (promptParts.length === 0) {
throw new Error('Generation prompts are empty');
}
requestChatForceScrollBottom(generationSession.sessionId);
const response = await opencodeClient.withDirectory(directory, async () => {
return opencodeClient.getApiClient().session.prompt({
sessionID: generationSession.sessionId,
...(trimmedDirectory.length > 0 ? { directory: trimmedDirectory } : {}),
model: {
providerID: generationSession.providerID,
modelID: generationSession.modelID,
},
...(generationSession.agent ? { agent: generationSession.agent } : {}),
...(generationSession.variant ? { variant: generationSession.variant } : {}),
parts: promptParts,
});
});
const responseError = response?.error as { message?: string } | undefined;
if (!response?.data) {
throw new Error(responseError?.message || `Failed to generate ${kind} output`);
}
const info = response.data.info as { finish?: string; error?: unknown };
const assistantText = extractAssistantText(response);
const parsedOutput = extractJsonObject(assistantText);
if (!parsedOutput) {
console.error('[git-generation][browser] invalid JSON output', {
kind,
sessionId: generationSession.sessionId,
elapsedMs: Date.now() - requestStartedAt,
finish: info?.finish,
assistantText,
messageInfo: response.data.info,
messageParts: response.data.parts,
});
throw new Error('No JSON output returned by session');
}
return parsedOutput;
};
export async function listGitWorktrees(directory: string): Promise<import('./api/types').GitWorktreeInfo[]> {
const runtime = getRuntimeGit();
if (runtime?.worktree?.list) {
return runtime.worktree.list(directory);
}
if (runtime) return runtime.listGitWorktrees(directory);
return gitHttp.listGitWorktrees(directory);
}
export async function validateGitWorktree(
directory: string,
payload: import('./api/types').CreateGitWorktreePayload
): Promise<import('./api/types').GitWorktreeValidationResult> {
const runtime = getRuntimeGit();
if (runtime?.worktree?.validate) {
return runtime.worktree.validate(directory, payload);
}
if (runtime?.validateGitWorktree) {
return runtime.validateGitWorktree(directory, payload);
}
return gitHttp.validateGitWorktree(directory, payload);
}
export async function getGitWorktreeBootstrapStatus(
directory: string,
): Promise<import('./api/types').GitWorktreeBootstrapStatus> {
const runtime = getRuntimeGit();
if (runtime?.worktree?.bootstrapStatus) {
return runtime.worktree.bootstrapStatus(directory);
}
if (runtime?.getGitWorktreeBootstrapStatus) {
return runtime.getGitWorktreeBootstrapStatus(directory);
}
return gitHttp.getGitWorktreeBootstrapStatus(directory);
}
export async function previewGitWorktree(
directory: string,
payload: import('./api/types').CreateGitWorktreePayload
): Promise<import('./api/types').GitWorktreeCreateResult> {
const runtime = getRuntimeGit();
if (runtime?.worktree?.preview) {
return runtime.worktree.preview(directory, payload);
}
if (runtime?.previewGitWorktree) {
return runtime.previewGitWorktree(directory, payload);
}
return gitHttp.previewGitWorktree(directory, payload);
}
export async function createGitWorktree(
directory: string,
payload: import('./api/types').CreateGitWorktreePayload
): Promise<import('./api/types').GitWorktreeCreateResult> {
const runtime = getRuntimeGit();
if (runtime?.worktree?.create) {
return runtime.worktree.create(directory, payload);
}
if (runtime?.createGitWorktree) {
return runtime.createGitWorktree(directory, payload);
}
return gitHttp.createGitWorktree(directory, payload);
}
export async function deleteGitWorktree(
directory: string,
payload: import('./api/types').RemoveGitWorktreePayload
): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime?.worktree?.remove) {
return runtime.worktree.remove(directory, payload);
}
if (runtime?.deleteGitWorktree) {
return runtime.deleteGitWorktree(directory, payload);
}
return gitHttp.deleteGitWorktree(directory, payload);
}
export const git = {
worktree: {
list: listGitWorktrees,
validate: validateGitWorktree,
create: createGitWorktree,
remove: deleteGitWorktree,
},
};
export async function createGitCommit(
directory: string,
message: string,
options: import('./api/types').CreateGitCommitOptions = {}
): Promise<import('./api/types').GitCommitResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.createGitCommit(directory, message, options);
return gitHttp.createGitCommit(directory, message, options);
}
export async function gitPush(
directory: string,
options: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> } = {}
): Promise<import('./api/types').GitPushResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.gitPush(directory, options);
return gitHttp.gitPush(directory, options);
}
export async function gitPull(
directory: string,
options: import('./api/types').GitPullOptions = {}
): Promise<import('./api/types').GitPullResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.gitPull(directory, options);
return gitHttp.gitPull(directory, options);
}
export async function gitFetch(
directory: string,
options: { remote?: string; branch?: string } = {}
): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.gitFetch(directory, options);
return gitHttp.gitFetch(directory, options);
}
export async function listGitStashes(directory: string): Promise<{ stashes: import('./api/types').GitStashEntry[] }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.listGitStashes(directory);
return gitHttp.listGitStashes(directory);
}
export async function countGitStashFiles(directory: string, refs: string[]): Promise<{ counts: Record<string, number> }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.countGitStashFiles(directory, refs);
return gitHttp.countGitStashFiles(directory, refs);
}
export async function stashGitChanges(directory: string, options: { message?: string } = {}): Promise<{ success: boolean; created: boolean; message: string; output: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.stashGitChanges(directory, options);
return gitHttp.stashGitChanges(directory, options);
}
export async function applyGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.applyGitStash(directory, options);
return gitHttp.applyGitStash(directory, options);
}
export async function popGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.popGitStash(directory, options);
return gitHttp.popGitStash(directory, options);
}
export async function dropGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.dropGitStash(directory, options);
return gitHttp.dropGitStash(directory, options);
}
export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.checkoutBranch(directory, branch);
return gitHttp.checkoutBranch(directory, branch);
}
export async function createBranch(
directory: string,
name: string,
startPoint?: string
): Promise<{ success: boolean; branch: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.createBranch(directory, name, startPoint);
return gitHttp.createBranch(directory, name, startPoint);
}
export async function renameBranch(
directory: string,
oldName: string,
newName: string
): Promise<{ success: boolean; branch: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.renameBranch(directory, oldName, newName);
return gitHttp.renameBranch(directory, oldName, newName);
}
export async function getGitLog(
directory: string,
options: import('./api/types').GitLogOptions = {}
): Promise<import('./api/types').GitLogResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getGitLog(directory, options);
return gitHttp.getGitLog(directory, options);
}
export async function getCommitFiles(
directory: string,
hash: string
): Promise<import('./api/types').GitCommitFilesResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getCommitFiles(directory, hash);
return gitHttp.getCommitFiles(directory, hash);
}
export async function getCommitFileDiff(
directory: string,
hash: string,
filePath: string,
isBinary: boolean
): Promise<import('./api/types').CommitFileDiffResponse> {
const runtime = getRuntimeGit();
if (runtime?.getCommitFileDiff) return runtime.getCommitFileDiff(directory, hash, filePath, isBinary);
return gitHttp.getCommitFileDiff(directory, hash, filePath, isBinary);
}
export async function getGitIdentities(): Promise<import('./api/types').GitIdentityProfile[]> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getGitIdentities();
return gitHttp.getGitIdentities();
}
export async function createGitIdentity(profile: import('./api/types').GitIdentityProfile): Promise<import('./api/types').GitIdentityProfile> {
const runtime = getRuntimeGit();
if (runtime) return runtime.createGitIdentity(profile);
return gitHttp.createGitIdentity(profile);
}
export async function updateGitIdentity(id: string, updates: import('./api/types').GitIdentityProfile): Promise<import('./api/types').GitIdentityProfile> {
const runtime = getRuntimeGit();
if (runtime) return runtime.updateGitIdentity(id, updates);
return gitHttp.updateGitIdentity(id, updates);
}
export async function deleteGitIdentity(id: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime) return runtime.deleteGitIdentity(id);
return gitHttp.deleteGitIdentity(id);
}
export async function getCurrentGitIdentity(directory: string): Promise<import('./api/types').GitIdentitySummary | null> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getCurrentGitIdentity(directory);
return gitHttp.getCurrentGitIdentity(directory);
}
export async function hasLocalIdentity(directory: string): Promise<boolean> {
const runtime = getRuntimeGit();
if (runtime?.hasLocalIdentity) return runtime.hasLocalIdentity(directory);
return gitHttp.hasLocalIdentity(directory);
}
export async function setGitIdentity(
directory: string,
profileId: string
): Promise<{ success: boolean; profile: import('./api/types').GitIdentityProfile }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.setGitIdentity(directory, profileId);
return gitHttp.setGitIdentity(directory, profileId);
}
export async function discoverGitCredentials(): Promise<import('./api/types').DiscoveredGitCredential[]> {
const runtime = getRuntimeGit();
if (runtime?.discoverGitCredentials) return runtime.discoverGitCredentials();
return gitHttp.discoverGitCredentials();
}
export async function getGlobalGitIdentity(): Promise<import('./api/types').GitIdentitySummary | null> {
const runtime = getRuntimeGit();
if (runtime?.getGlobalGitIdentity) return runtime.getGlobalGitIdentity();
return gitHttp.getGlobalGitIdentity();
}
export async function getRemoteUrl(directory: string, remote?: string): Promise<string | null> {
const runtime = getRuntimeGit();
if (runtime?.getRemoteUrl) return runtime.getRemoteUrl(directory, remote);
return gitHttp.getRemoteUrl(directory, remote);
}
export async function getRemotes(directory: string): Promise<import('./api/types').GitRemote[]> {
const runtime = getRuntimeGit();
if (runtime) return runtime.getRemotes(directory);
return gitHttp.getRemotes(directory);
}
export async function removeRemote(
directory: string,
payload: import('./api/types').GitRemoveRemotePayload
): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.removeRemote(directory, payload);
return gitHttp.removeRemote(directory, payload);
}
export async function rebase(
directory: string,
options: { onto: string }
): Promise<import('./api/types').GitRebaseResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.rebase(directory, options);
return gitHttp.rebase(directory, options);
}
export async function abortRebase(directory: string): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.abortRebase(directory);
return gitHttp.abortRebase(directory);
}
export async function merge(
directory: string,
options: { branch: string }
): Promise<import('./api/types').GitMergeResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.merge(directory, options);
return gitHttp.merge(directory, options);
}
export async function checkoutCommit(
directory: string,
hash: string
): Promise<import('./api/types').CheckoutCommitResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.checkoutCommit(directory, hash);
return gitHttp.checkoutCommit(directory, hash);
}
export async function cherryPick(
directory: string,
hash: string
): Promise<import('./api/types').CherryPickResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.cherryPick(directory, hash);
return gitHttp.cherryPick(directory, hash);
}
export async function revertCommit(
directory: string,
hash: string
): Promise<import('./api/types').RevertCommitResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.revertCommit(directory, hash);
return gitHttp.revertCommit(directory, hash);
}
export async function resetToCommit(
directory: string,
hash: string,
mode: 'soft' | 'mixed' | 'hard',
force?: boolean
): Promise<import('./api/types').ResetToCommitResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.resetToCommit(directory, hash, mode, force);
return gitHttp.resetToCommit(directory, hash, mode, force);
}
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.abortMerge(directory);
return gitHttp.abortMerge(directory);
}
export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.continueRebase(directory);
return gitHttp.continueRebase(directory);
}
export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.continueMerge(directory);
return gitHttp.continueMerge(directory);
}
export async function stash(
directory: string,
options?: { message?: string; includeUntracked?: boolean }
): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.stash(directory, options);
return gitHttp.stash(directory, options);
}
export async function stashPop(directory: string): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.stashPop(directory);
return gitHttp.stashPop(directory);
}
export async function getConflictDetails(directory: string): Promise<import('./api/types').MergeConflictDetails> {
const runtime = getRuntimeGit();
if (runtime?.getConflictDetails) return runtime.getConflictDetails(directory);
return gitHttp.getConflictDetails(directory);
}
export async function validateWorktreeDirectory(
directory: string,
worktreeRoot: string
): Promise<{
valid: boolean;
insideWorktreeRoot: boolean;
resolvedWorktreeRoot: string | null;
resolvedCwd: string | null;
}> {
const runtime = getRuntimeGit();
if (runtime?.validateWorktreeDirectory) {
return runtime.validateWorktreeDirectory(directory, worktreeRoot);
}
return gitHttp.validateWorktreeDirectory(directory, worktreeRoot);
}
export async function canonicalizeWorktreeState(
directory: string
): Promise<{
worktreeRoot: string | null;
cwd: string | null;
branch: string | null;
headState: 'branch' | 'detached' | 'unborn';
worktreeStatus: 'pending' | 'ready' | 'missing' | 'invalid' | 'not-a-repo';
legacy: boolean;
degraded: boolean;
attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null;
}> {
const runtime = getRuntimeGit();
if (runtime?.canonicalizeWorktreeState) {
return runtime.canonicalizeWorktreeState(directory);
}
return gitHttp.canonicalizeWorktreeState(directory);
}