Files
openchamber/packages/ui/src/lib/gitApiHttp.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

1153 lines
38 KiB
TypeScript

import type {
GitStatus,
GitDiffResponse,
GetGitDiffOptions,
GitFileDiffResponse,
GetGitFileDiffOptions,
GitBranch,
GitDeleteBranchPayload,
GitDeleteRemoteBranchPayload,
GitRemoveRemotePayload,
GeneratedCommitMessage,
GitWorktreeInfo,
CreateGitWorktreePayload,
GitWorktreeCreateResult,
RemoveGitWorktreePayload,
GitWorktreeValidationResult,
CreateGitCommitOptions,
GitCommitResult,
GitPushResult,
GitPullResult,
GitPullOptions,
GitStashEntry,
GitLogOptions,
GitLogResponse,
GitCommitFilesResponse,
CommitFileDiffResponse,
GitIdentityProfile,
GitIdentitySummary,
DiscoveredGitCredential,
MergeConflictDetails,
CheckoutCommitResponse,
CherryPickResponse,
RevertCommitResponse,
ResetToCommitResponse,
} from './api/types';
import { runtimeFetch } from './runtime-fetch';
import { getRuntimeUrlResolver } from './runtime-url';
const API_BASE = '/api/git';
const GIT_STATUS_CACHE_TTL_MS = 1200;
const GIT_REPO_CHECK_CACHE_TTL_MS = 5000;
const gitStatusCache = new Map<string, { value: GitStatus; expiresAt: number }>();
const gitStatusInFlight = new Map<string, Promise<GitStatus>>();
const gitRepoCache = new Map<string, { value: boolean; expiresAt: number }>();
const gitRepoInFlight = new Map<string, Promise<boolean>>();
const normalizeDirectoryKey = (directory: string): string => directory.trim();
function buildUrl(
path: string,
directory: string | null | undefined,
params?: Record<string, string | number | boolean | undefined>
): string {
const query: Record<string, string | number | boolean | undefined> = { ...params };
if (directory) query.directory = directory;
return getRuntimeUrlResolver().api(path, query);
}
export async function checkIsGitRepository(directory: string): Promise<boolean> {
const key = normalizeDirectoryKey(directory);
const now = Date.now();
const cached = gitRepoCache.get(key);
if (cached && cached.expiresAt > now) {
return cached.value;
}
const inFlight = gitRepoInFlight.get(key);
if (inFlight) {
return inFlight;
}
const task = (async () => {
const response = await runtimeFetch(buildUrl(`${API_BASE}/check`, directory));
if (!response.ok) {
throw new Error(`Failed to check git repository: ${response.statusText}`);
}
const data = await response.json();
const isGitRepository = Boolean(data.isGitRepository);
gitRepoCache.set(key, {
value: isGitRepository,
expiresAt: Date.now() + GIT_REPO_CHECK_CACHE_TTL_MS,
});
return isGitRepository;
})();
gitRepoInFlight.set(key, task);
try {
return await task;
} finally {
if (gitRepoInFlight.get(key) === task) {
gitRepoInFlight.delete(key);
}
}
}
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> {
const mode = options?.mode;
const key = mode === 'light' ? `${normalizeDirectoryKey(directory)}::light` : normalizeDirectoryKey(directory);
const now = Date.now();
const cached = gitStatusCache.get(key);
if (cached && cached.expiresAt > now) {
return cached.value;
}
const inFlight = gitStatusInFlight.get(key);
if (inFlight) {
return inFlight;
}
const task = (async () => {
const response = await runtimeFetch(buildUrl(`${API_BASE}/status`, directory, mode ? { mode } : undefined));
if (!response.ok) {
throw new Error(`Failed to get git status: ${response.statusText}`);
}
const payload = await response.json() as GitStatus;
gitStatusCache.set(key, {
value: payload,
expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS,
});
return payload;
})();
gitStatusInFlight.set(key, task);
try {
return await task;
} finally {
if (gitStatusInFlight.get(key) === task) {
gitStatusInFlight.delete(key);
}
}
}
export async function resolveGitPrimaryRoot(directory: string): Promise<{ root: string }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/primary-root`, directory));
if (!response.ok) {
throw new Error(`Failed to resolve git primary root: ${response.statusText}`);
}
const payload = await response.json().catch(() => ({})) as { root?: string };
return { root: typeof payload.root === 'string' && payload.root ? payload.root : directory };
}
export async function resolveGitTopLevel(directory: string): Promise<{ root: string }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/toplevel`, directory));
if (!response.ok) {
throw new Error(`Failed to resolve git toplevel: ${response.statusText}`);
}
const payload = await response.json().catch(() => ({})) as { root?: string };
return { root: typeof payload.root === 'string' && payload.root ? payload.root : directory };
}
export async function getGitCommitSummaries(
directory: string,
shas: string[]
): Promise<{ commits: Array<{ sha: string; short: string; subject: string }> }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/commit-summaries`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shas }),
});
if (!response.ok) {
throw new Error(`Failed to get git commit summaries: ${response.statusText}`);
}
const payload = await response.json().catch(() => ({})) as {
commits?: Array<{ sha?: string; short?: string; subject?: string }>;
};
return {
commits: Array.isArray(payload.commits)
? payload.commits
.map((entry) => ({
sha: typeof entry.sha === 'string' ? entry.sha : '',
short: typeof entry.short === 'string' ? entry.short : '',
subject: typeof entry.subject === 'string' ? entry.subject : '',
}))
.filter((entry) => entry.sha && entry.short)
: [],
};
}
export async function getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse> {
const { path, staged, contextLines } = options;
if (!path) {
throw new Error('path is required to fetch git diff');
}
const response = await runtimeFetch(
buildUrl(`${API_BASE}/diff`, directory, {
path,
staged: staged ? 'true' : undefined,
context: contextLines,
})
);
if (!response.ok) {
throw new Error(`Failed to get git diff: ${response.statusText}`);
}
return response.json();
}
export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse> {
const { path, staged } = options;
if (!path) {
throw new Error('path is required to fetch git file diff');
}
const response = await runtimeFetch(
buildUrl(`${API_BASE}/file-diff`, directory, {
path,
staged: staged ? 'true' : undefined,
})
);
if (!response.ok) {
throw new Error(`Failed to get git file diff: ${response.statusText}`);
}
return response.json();
}
export async function revertGitFile(
directory: string,
filePath: string,
options?: { scope?: 'all' | 'working' }
): Promise<void> {
if (!filePath) {
throw new Error('path is required to revert git changes');
}
const response = await runtimeFetch(buildUrl(`${API_BASE}/revert`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: filePath, scope: options?.scope }),
});
if (!response.ok) {
const message = await response
.json()
.catch(() => ({ error: response.statusText }));
throw new Error(message.error || 'Failed to revert git changes');
}
}
export async function stageGitFile(directory: string, filePath: string): Promise<void> {
await stageGitFiles(directory, [filePath]);
}
export async function stageGitFiles(directory: string, filePaths: string[]): Promise<void> {
const paths = filePaths.map((path) => path.trim()).filter(Boolean);
if (paths.length === 0) {
throw new Error('path is required to stage git changes');
}
const response = await runtimeFetch(buildUrl(`${API_BASE}/stage`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths }),
});
if (!response.ok) {
const message = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(message.error || 'Failed to stage git changes');
}
}
export async function unstageGitFile(directory: string, filePath: string): Promise<void> {
await unstageGitFiles(directory, [filePath]);
}
export async function unstageGitFiles(directory: string, filePaths: string[]): Promise<void> {
const paths = filePaths.map((path) => path.trim()).filter(Boolean);
if (paths.length === 0) {
throw new Error('path is required to unstage git changes');
}
const response = await runtimeFetch(buildUrl(`${API_BASE}/unstage`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths }),
});
if (!response.ok) {
const message = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(message.error || 'Failed to unstage git changes');
}
}
export async function stageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
await applyGitHunk(directory, filePath, patch, 'stage');
}
export async function unstageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
await applyGitHunk(directory, filePath, patch, 'unstage');
}
export async function revertGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
await applyGitHunk(directory, filePath, patch, 'discard');
}
async function applyGitHunk(
directory: string,
filePath: string,
patch: string,
action: 'stage' | 'unstage' | 'discard',
): Promise<void> {
if (!filePath) {
throw new Error('path is required to apply a git hunk');
}
if (typeof patch !== 'string' || !patch.trim()) {
throw new Error('patch is required to apply a git hunk');
}
const response = await runtimeFetch(buildUrl(`${API_BASE}/apply-hunk`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: filePath, patch, action }),
});
if (!response.ok) {
const message = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(message.error || 'Failed to apply git hunk');
}
}
export async function isLinkedWorktree(directory: string): Promise<boolean> {
if (!directory) {
return false;
}
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktree-type`, directory));
if (!response.ok) {
throw new Error(`Failed to detect worktree type: ${response.statusText}`);
}
const data = await response.json();
return Boolean(data.linked);
}
export async function getGitBranches(directory: string): Promise<GitBranch> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/branches`, directory));
if (!response.ok) {
throw new Error(`Failed to get branches: ${response.statusText}`);
}
return response.json();
}
export async function deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> {
if (!payload?.branch) {
throw new Error('branch is required to delete a branch');
}
const response = await runtimeFetch(buildUrl(`${API_BASE}/branches`, directory), {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to delete branch');
}
return response.json();
}
export async function deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
if (!payload?.branch) {
throw new Error('branch is required to delete remote branch');
}
const response = await runtimeFetch(buildUrl(`${API_BASE}/remote-branches`, directory), {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to delete remote branch');
}
return response.json();
}
export async function removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }> {
const remote = payload?.remote?.trim();
if (!remote) {
throw new Error('remote is required to remove a remote');
}
const response = await runtimeFetch(buildUrl(`${API_BASE}/remotes`, directory), {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ remote }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to remove remote');
}
return response.json();
}
export async function generateCommitMessage(
directory: string,
files: string[],
options?: { zenModel?: string; providerId?: string; modelId?: string }
): Promise<{ message: GeneratedCommitMessage }> {
if (!Array.isArray(files) || files.length === 0) {
throw new Error('No files provided to generate commit message');
}
const body: Record<string, unknown> = { files };
if (options?.zenModel) {
body.zenModel = options.zenModel;
}
if (options?.providerId) {
body.providerId = options.providerId;
}
if (options?.modelId) {
body.modelId = options.modelId;
}
const response = await runtimeFetch(buildUrl(`${API_BASE}/commit-message`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
console.error('[git-generation][browser] http error', {
status: response.status,
statusText: response.statusText,
error,
});
const traceSuffix = typeof error?.traceId === 'string' && error.traceId
? ` (traceId: ${error.traceId})`
: '';
throw new Error(`${error.error || 'Failed to generate commit message'}${traceSuffix}`);
}
const data = await response.json();
if (!data?.message || typeof data.message !== 'object') {
throw new Error('Malformed commit generation response');
}
const subject =
typeof data.message.subject === 'string' && data.message.subject.trim().length > 0
? data.message.subject.trim()
: '';
const highlights: string[] = Array.isArray(data.message.highlights)
? (data.message.highlights as unknown[])
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
.map((item) => (item as string).trim())
: [];
return {
message: {
subject,
highlights,
},
};
}
export async function generatePullRequestDescription(
directory: string,
payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
): Promise<{ title: string; body: string }> {
const { base, head, context, zenModel, providerId, modelId } = payload;
if (!base || !head) {
throw new Error('base and head are required');
}
const requestBody: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } = { base, head };
if (context?.trim()) {
requestBody.context = context.trim();
}
if (zenModel) {
requestBody.zenModel = zenModel;
}
if (providerId) {
requestBody.providerId = providerId;
}
if (modelId) {
requestBody.modelId = modelId;
}
const response = await runtimeFetch(buildUrl(`${API_BASE}/pr-description`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to generate PR description');
}
const data = await response.json().catch(() => null);
const title = typeof data?.title === 'string' ? data.title : '';
const body = typeof data?.body === 'string' ? data.body : '';
if (!title && !body) {
throw new Error('Malformed PR description response');
}
return { title, body };
}
export async function listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees`, directory));
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to list worktrees');
}
return response.json();
}
export async function validateGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees/validate`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload ?? {}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to validate worktree');
}
return response.json();
}
export async function getGitWorktreeBootstrapStatus(directory: string): Promise<import('./api/types').GitWorktreeBootstrapStatus> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees/bootstrap-status`, directory));
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to get worktree bootstrap status');
}
return response.json();
}
export async function previewGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees/preview`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload ?? {}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to preview worktree');
}
return response.json();
}
export async function createGitWorktree(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeCreateResult> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload ?? {}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to create worktree');
}
return response.json();
}
export async function deleteGitWorktree(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/worktrees`, directory), {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload ?? {}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to delete worktree');
}
return response.json();
}
export async function createGitCommit(
directory: string,
message: string,
options: CreateGitCommitOptions = {}
): Promise<GitCommitResult> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/commit`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
addAll: options.addAll ?? false,
files: options.files,
stageFiles: options.stageFiles,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to create commit');
}
return response.json();
}
export async function gitPush(
directory: string,
options: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> } = {}
): Promise<GitPushResult> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/push`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to push');
}
return response.json();
}
export async function gitPull(
directory: string,
options: GitPullOptions = {}
): Promise<GitPullResult> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/pull`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to pull');
}
return response.json();
}
export async function gitFetch(
directory: string,
options: { remote?: string; branch?: string } = {}
): Promise<{ success: boolean }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/fetch`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to fetch');
}
return response.json();
}
export async function listGitStashes(directory: string): Promise<{ stashes: GitStashEntry[] }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/stashes`, directory));
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to list stashes');
}
return response.json();
}
export async function countGitStashFiles(directory: string, refs: string[]): Promise<{ counts: Record<string, number> }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/stashes/file-counts`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refs }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to count stash files');
}
return response.json();
}
export async function stashGitChanges(directory: string, options: { message?: string } = {}): Promise<{ success: boolean; created: boolean; message: string; output: string }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/stash`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to stash changes');
}
return response.json();
}
const postStashRef = async (directory: string, path: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> => {
const response = await runtimeFetch(buildUrl(`${API_BASE}/${path}`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || `Failed to ${path}`);
}
return response.json();
};
export const applyGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/apply', options);
export const popGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/pop', options);
export const dropGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/drop', options);
export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/checkout`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branch }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to checkout branch');
}
return response.json();
}
export async function createBranch(
directory: string,
name: string,
startPoint?: string
): Promise<{ success: boolean; branch: string }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/branches`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, startPoint }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to create branch');
}
return response.json();
}
export async function renameBranch(
directory: string,
oldName: string,
newName: string
): Promise<{ success: boolean; branch: string }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/branches/rename`, directory), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldName, newName }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to rename branch');
}
return response.json();
}
export async function getGitLog(
directory: string,
options: GitLogOptions = {}
): Promise<GitLogResponse> {
const response = await runtimeFetch(
buildUrl(`${API_BASE}/log`, directory, {
maxCount: options.maxCount,
from: options.from,
to: options.to,
file: options.file,
all: options.all ? 'true' : undefined,
})
);
if (!response.ok) {
const errorBody = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(`Failed to get git log: ${errorBody.error || response.statusText}`);
}
return response.json();
}
export async function getCommitFiles(
directory: string,
hash: string
): Promise<GitCommitFilesResponse> {
const response = await runtimeFetch(
buildUrl(`${API_BASE}/commit-files`, directory, { hash })
);
if (!response.ok) {
throw new Error(`Failed to get commit files: ${response.statusText}`);
}
return response.json();
}
export async function getCommitFileDiff(
directory: string,
hash: string,
filePath: string,
isBinary: boolean
): Promise<CommitFileDiffResponse> {
const response = await runtimeFetch(
buildUrl(`${API_BASE}/commit-file-diff`, directory, {
hash,
path: filePath,
binary: isBinary ? 'true' : undefined,
})
);
if (!response.ok) {
throw new Error(`Failed to get commit file diff: ${response.statusText}`);
}
return response.json();
}
export async function getGitIdentities(): Promise<GitIdentityProfile[]> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/identities`, undefined));
if (!response.ok) {
throw new Error(`Failed to get git identities: ${response.statusText}`);
}
return response.json();
}
export async function createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/identities`, undefined), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profile),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to create git identity');
}
return response.json();
}
export async function updateGitIdentity(id: string, updates: GitIdentityProfile): Promise<GitIdentityProfile> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to update git identity');
}
return response.json();
}
export async function deleteGitIdentity(id: string): Promise<void> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/identities/${id}`, undefined), {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to delete git identity');
}
}
export async function getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null> {
if (!directory) {
return null;
}
const response = await runtimeFetch(buildUrl(`${API_BASE}/current-identity`, directory));
if (!response.ok) {
throw new Error(`Failed to get current git identity: ${response.statusText}`);
}
const data = await response.json();
if (!data) {
return null;
}
return {
userName: data.userName ?? null,
userEmail: data.userEmail ?? null,
sshCommand: data.sshCommand ?? null,
};
}
export async function hasLocalIdentity(directory: string): Promise<boolean> {
if (!directory) {
return false;
}
const response = await runtimeFetch(buildUrl(`${API_BASE}/has-local-identity`, directory));
if (!response.ok) {
throw new Error(`Failed to check local identity: ${response.statusText}`);
}
const data = await response.json().catch(() => null);
return data?.hasLocalIdentity === true;
}
export async function getGlobalGitIdentity(): Promise<GitIdentitySummary | null> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/global-identity`, undefined));
if (!response.ok) {
throw new Error(`Failed to get global git identity: ${response.statusText}`);
}
const data = await response.json();
if (!data || (!data.userName && !data.userEmail)) {
return null;
}
return {
userName: data.userName ?? null,
userEmail: data.userEmail ?? null,
sshCommand: data.sshCommand ?? null,
};
}
export async function setGitIdentity(
directory: string,
profileId: string
): Promise<{ success: boolean; profile: GitIdentityProfile }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/set-identity`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profileId }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to set git identity');
}
return response.json();
}
export async function discoverGitCredentials(): Promise<DiscoveredGitCredential[]> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/discover-credentials`, undefined));
if (!response.ok) {
throw new Error(`Failed to discover git credentials: ${response.statusText}`);
}
return response.json();
}
export async function getRemoteUrl(directory: string, remote?: string): Promise<string | null> {
if (!directory) {
return null;
}
const response = await runtimeFetch(buildUrl(`${API_BASE}/remote-url`, directory, { remote }));
if (!response.ok) {
return null;
}
const data = await response.json();
return data.url ?? null;
}
export async function getRemotes(directory: string): Promise<Array<{ name: string; fetchUrl: string; pushUrl: string }>> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/remotes`, directory));
if (!response.ok) {
throw new Error(`Failed to get remotes: ${response.statusText}`);
}
return response.json();
}
export async function rebase(
directory: string,
options: { onto: string }
): Promise<{ success: boolean; conflict?: boolean; conflictFiles?: string[] }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/rebase`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to rebase');
}
return response.json();
}
export async function abortRebase(directory: string): Promise<{ success: boolean }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/rebase/abort`, directory), {
method: 'POST',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to abort rebase');
}
return response.json();
}
export async function merge(
directory: string,
options: { branch: string }
): Promise<{ success: boolean; conflict?: boolean; conflictFiles?: string[] }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/merge`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to merge');
}
return response.json();
}
export async function checkoutCommit(
directory: string,
hash: string
): Promise<CheckoutCommitResponse> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/checkout-commit`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to checkout commit');
}
return response.json();
}
export async function cherryPick(
directory: string,
hash: string
): Promise<CherryPickResponse> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/cherry-pick`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to cherry-pick');
}
return response.json();
}
export async function revertCommit(
directory: string,
hash: string
): Promise<RevertCommitResponse> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/revert-commit`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to revert commit');
}
return response.json();
}
export async function resetToCommit(
directory: string,
hash: string,
mode: 'soft' | 'mixed' | 'hard',
force?: boolean
): Promise<ResetToCommitResponse> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/reset-to-commit`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash, mode, force }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to reset');
}
return response.json();
}
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/merge/abort`, directory), {
method: 'POST',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to abort merge');
}
return response.json();
}
export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/rebase/continue`, directory), {
method: 'POST',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to continue rebase');
}
return response.json();
}
export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/merge/continue`, directory), {
method: 'POST',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to continue merge');
}
return response.json();
}
export async function stash(
directory: string,
options?: { message?: string; includeUntracked?: boolean }
): Promise<{ success: boolean }> {
await stashGitChanges(directory, { message: options?.message });
return { success: true };
}
export async function stashPop(directory: string): Promise<{ success: boolean }> {
await popGitStash(directory, { ref: 'stash@{0}' });
return { success: true };
}
export async function getConflictDetails(directory: string): Promise<MergeConflictDetails> {
const response = await runtimeFetch(buildUrl(`${API_BASE}/conflict-details`, directory));
if (!response.ok) {
throw new Error(`Failed to get conflict details: ${response.statusText}`);
}
return response.json();
}
export async function validateWorktreeDirectory(
directory: string,
worktreeRoot: string
): Promise<{
valid: boolean;
insideWorktreeRoot: boolean;
resolvedWorktreeRoot: string | null;
resolvedCwd: string | null;
}> {
const response = await runtimeFetch(`${API_BASE}/validate-directory`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ directory, worktreeRoot }),
});
if (!response.ok) {
throw new Error(`Failed to validate worktree directory: ${response.statusText}`);
}
return response.json();
}
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 response = await runtimeFetch(`${API_BASE}/canonicalize-worktree-state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ directory }),
});
if (!response.ok) {
throw new Error(`Failed to canonicalize worktree state: ${response.statusText}`);
}
return response.json();
}