A diff is ordered by file path, which is almost never the order in which a change makes sense. This adds a Walkthrough surface that reorders it: the model groups related hunks into stops, explains what each group changes about behavior, and orders the stops so each builds on the last. It explains and orders; judging code stays with the existing Review action. Reviews uncommitted work (all, staged, unstaged), a branch against its base, or a pull request. Generation is always user-initiated — nothing runs on a timer, on a file change, or as a side effect of opening a panel. Invariants worth preserving: - Hunk identity is derived on the server and only there. Ids are content hashes, so an anchor that no longer resolves is proof the code it described changed, and staleness needs no heuristics. The client matches ids to ids and never recomputes them; two implementations would have to agree forever. - The digest is never truncated. A diff that does not fit the model's context is refused with an actionable reason, because a walkthrough written against half a diff reads as confident and is wrong. - Nothing disappears. Lockfiles and other generated output are excluded from the model's input by name — never by size — and everything no stop covers is listed at the end, so "have I seen all of it" stays answerable. - Cost is explicit. Results are content-addressed, so returning the working tree to an earlier state costs nothing; generation outlives its request, so a refresh detaches the client rather than discarding paid-for work, and only an explicit cancel stops it. Supporting changes to shared modules: - git: expose the existing getRangeDiff as GET /api/git listUntrackedPaths and getUntrackedDiffs. The latter resolve the repository once for a batch instead of per file, taking a panel ~340ms on an 80-file working tree. - small-model: structured output across four wire forma and abort signal, and an onOverflow policy so an oversized prompt fails loudly instead of being silently clipped. A provider remembered so the prompt-side fallback goes first next time. - models.dev metadata: surface structured_output as tri false blocks a model, a missing field does not, because the catalog omits it for roughly half of all models. Desktop and tablet only: VS Code serves Git through its these routes, and the mobile shell does not consume the surface registry. Docs: packages/docs walkthrough page in English and all eight locales.
1106 lines
40 KiB
TypeScript
1106 lines
40 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 {
|
|
GitStatus,
|
|
GitDiffResponse,
|
|
GetGitDiffOptions,
|
|
GitBranchDetails,
|
|
GitBranch,
|
|
GitCommitResult,
|
|
GitPushResult,
|
|
GitPullResult,
|
|
GitIdentityProfile,
|
|
GitIdentityAuthType,
|
|
GitIdentitySummary,
|
|
GitLogEntry,
|
|
GitLogResponse,
|
|
GitWorktreeInfo,
|
|
CreateGitWorktreePayload,
|
|
GitWorktreeCreateResult,
|
|
RemoveGitWorktreePayload,
|
|
GitWorktreeValidationError,
|
|
GitWorktreeValidationResult,
|
|
GitDeleteBranchPayload,
|
|
GitDeleteRemoteBranchPayload,
|
|
GitRemoveRemotePayload,
|
|
DiscoveredGitCredential,
|
|
GitRemote,
|
|
GitMergeResult,
|
|
GitRebaseResult,
|
|
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);
|
|
}
|