Files
openchamber/packages/ui/src/lib/gitApi.ts
T
Bohdan Triapitsyn f645d57c93 Stage, unstage, and discard individual diff hunks
Add per-hunk staging, unstaging, and discarding to the Changes diff
view, so a single change region inside a file can be acted on in
isolation instead of forcing whole-file stage/revert. The change is
wired end-to-end across the web server, the shared UI runtime API
contract, and the VS Code extension, with Electron inheriting the web
path unchanged (it boots the server in-process).

Server
------
- New `applyHunk(directory, filePath, { patch, action })` in
  packages/web/server/lib/git/service.js. It resolves the repository
  context and validates the file path with the same helpers used by
  stageFiles/unstageFiles (resolveGitFileContext +
  validateRepositoryFilePaths), then writes the single-hunk patch to a
  temporary file in the OS temp dir (never inside the repo, so it
  cannot show up as an untracked file) and runs `git apply` with flags
  chosen per action:
    stage   -> git apply --cached          (working tree -> index)
    unstage -> git apply --cached --reverse (index -> working tree)
    discard -> git apply --reverse          (revert in working tree)
  A `git apply --check` runs first with the same flags, so a stale
  hunk that no longer applies fails with a clear "Hunk no longer
  applies - refresh and try again" message instead of leaving a
  partial mutation. The patch's target path is parsed and must match
  the requested file (with /dev/null tolerated for new/deleted files),
  preventing a patch from silently targeting a different path. The
  whole operation runs inside withGitIndexMutationQueue to avoid
  racing with concurrent stage/unstage. The temp file is removed in a
  finally block.
- New `POST /api/git/apply-hunk` route in routes.js, registered
  alongside stage/unstage. Validates directory, path, non-empty patch,
  and action before delegating.
- DOCUMENTATION.md updated with the new service entry.

Patch extraction
----------------
- packages/ui/src/lib/diff/patchFileDiff.ts gains
  splitPatchIntoHunks(patch) and extractHunkPatch(patch, hunkIndex).
  They keep the original file header (diff --git / index / --- / +++)
  and emit exactly one @@ hunk per standalone patch, which is what
  `git apply` expects. Each emitted patch is guaranteed to end with a
  trailing newline (without it git apply reports "corrupt patch").

Runtime API contract
--------------------
- GitAPI (packages/ui/src/lib/api/types.ts) gains optional
  stageGitHunk / unstageGitHunk / revertGitHunk, matching the
  stageGitFiles? / unstageGitFiles? precedent so runtimes that do not
  support it degrade gracefully.
- gitApi.ts delegates to the registered runtime git API, falling back
  to gitApiHttp, exactly like the existing whole-file helpers.
- gitApiHttp.ts posts to /api/git/apply-hunk.
- Web runtime composes the three methods in packages/web/src/api/git.ts.

VS Code parity
--------------
- packages/vscode/src/gitService.ts adds applyGitHunk(), implemented
  natively with the existing execGit helper + a temp patch file +
  `git apply` (--cached / --cached --reverse / --reverse), mirroring
  the server's --check-first safety and temp-file cleanup.
- bridge-git-runtime.ts handles the new api:git/apply-hunk bridge
  message; webview/api/git.ts sends it. VS Code users get identical
  stage/unstage/discard-hunk behavior.

UI
--
- New DiffHunkActions component renders a compact per-hunk strip
  above each expanded file diff in the Changes view. Each hunk chip
  shows its +additions / -deletions counts and offers:
    working scope -> Stage + Discard
    staged scope  -> Unstage
  Clicking extracts that hunk's standalone patch via
  extractHunkPatch(patch, hunkIndex) and calls the runtime git API.
  Because the chip index comes directly from fileDiff.hunks[] and the
  patch is sliced in the same order, the hunk the user sees is always
  the hunk that gets applied. While any action is in flight all buttons
  disable to prevent conflicting concurrent mutations; the per-hunk
  spinner reflects in-flight state.
- DiffView wires DiffHunkActions into InlineDiffViewer (text diffs
  only; binary/image and full-file-content modes are excluded since
  they have no patch). MultiFileDiffEntry passes directory/staged
  through and handles onHunkApplied by bumping the diff reload nonce
  (so the file's diff re-fetches and the affected hunk disappears)
  and refreshing git status (so file counts and the staged/changed
  scope update). Hunk actions are therefore available wherever the
  default patch-context diff is shown.

i18n
----
- 10 new keys (diffView.hunk.*) added to all 9 locales (en, es, fr,
  ko, pl, pt-BR, uk, zh-CN, zh-TW), including stage/unstage/discard
  labels, tooltips with the hunk index, a stale-hunk error message,
  and an unsupported-runtime fallback.

Tests
-----
- packages/ui/src/lib/diff/patchFileDiff.test.ts covers
  splitHunks/extractHunkPatch: multi-hunk split, header preservation,
  single-hunk and empty patches, out-of-range indices.
- service.test.js adds an applyHunk suite that builds real temp repos
  with two separate hunks and verifies: staging one hunk leaves the
  other unstaged, discarding reverts only the targeted hunk in the
  working tree, unstaging removes only one hunk from the index, and a
  retargeted patch (different file path) is rejected. Also covers
  invalid-action / missing-hunk-header validation.
- packages/web/src/api/git.test.ts mock completed with the new methods
  (and previously-missing exports that prevented the test from
  loading) and asserts the three hunk methods are exposed.
- routes.test.js continues to pass under bun.

CHANGELOG updated under [Unreleased].
2026-06-14 10:58:23 +03:00

933 lines
32 KiB
TypeScript

import * as gitHttp from './gitApiHttp';
import { opencodeClient } from './opencode/client';
import { renderMagicPrompt } from './magicPrompts';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useContextStore } from '@/stores/contextStore';
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 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);
}
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;
const generationSession = resolveSessionGenerationContext();
if (!generationSession) {
throw new Error('Select existing session for generation');
}
console.info('[git-generation][browser] request', {
transport: 'session',
kind: 'commit',
directory,
selectedFiles: files.length,
sessionId: generationSession.sessionId,
providerId: generationSession.providerID,
modelId: generationSession.modelID,
agent: generationSession.agent,
});
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 structured = await runStructuredGenerationInActiveSession({
directory,
visiblePrompt,
hiddenPrompt,
generationSession,
kind: 'commit',
});
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');
}
const result = { message: { subject, highlights } };
console.info('[git-generation][browser] success', {
transport: 'session',
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: 'session',
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 generationSession = resolveSessionGenerationContext();
if (!generationSession) {
throw new Error('Select existing session for generation');
}
const commitLog = await getGitLog(directory, {
from: payload.base,
to: payload.head,
maxCount: 50,
});
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() : '',
}));
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: 'session',
kind: 'pr',
directory,
sessionId: generationSession.sessionId,
providerId: generationSession.providerID,
modelId: generationSession.modelID,
agent: generationSession.agent,
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) => `- ${commit.hash.slice(0, 7)} ${commit.subject || '(no subject)'}`).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()}` : '',
});
try {
const structured = await runStructuredGenerationInActiveSession({
directory,
visiblePrompt,
hiddenPrompt,
generationSession,
kind: 'pr',
});
const result = {
title: typeof structured.title === 'string' ? structured.title.trim() : '',
body: typeof structured.body === 'string' ? structured.body.trim() : '',
};
console.info('[git-generation][browser] success', {
transport: 'session',
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: 'session',
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 resolveSessionGenerationContext = (): SessionGenerationContext | null => {
const sessionId = useSessionUIStore.getState().currentSessionId;
if (!sessionId) {
return null;
}
const context = useContextStore.getState();
const config = useConfigStore.getState();
const agent = context.getSessionAgentSelection(sessionId) || config.currentAgentName || undefined;
const sessionModel = context.getSessionModelSelection(sessionId);
const agentModel = agent ? context.getAgentModelForSession(sessionId, agent) : null;
const selectedModel = agentModel || sessionModel || (config.currentProviderId && config.currentModelId
? { providerId: config.currentProviderId, modelId: config.currentModelId }
: null);
if (!selectedModel?.providerId || !selectedModel?.modelId) {
return null;
}
const agentVariant = agent
? context.getAgentModelVariantForSession(sessionId, agent, selectedModel.providerId, selectedModel.modelId)
: undefined;
const variant = agentVariant || config.currentVariant || 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);
}