mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
1933 lines
65 KiB
TypeScript
1933 lines
65 KiB
TypeScript
/* eslint-disable max-lines */
|
|
import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
|
import { readdir, readFile, writeFile, stat, lstat, open, rename, rm } from 'fs/promises'
|
|
import { randomUUID } from 'crypto'
|
|
import { dirname, extname, join, resolve } from 'path'
|
|
import type { ChildProcess } from 'child_process'
|
|
import { gitExecFileAsync, wslAwareSpawn } from '../git/runner'
|
|
import { parseWslPath, toWindowsWslPath } from '../wsl'
|
|
import type { Store } from '../persistence'
|
|
import type {
|
|
DirEntry,
|
|
GitBranchCompareResult,
|
|
GitCommitCompareResult,
|
|
GitConflictOperation,
|
|
GitDiffResult,
|
|
GitForkSyncExpectedUpstream,
|
|
GitForkSyncResult,
|
|
GlobalSettings,
|
|
GitPushTarget,
|
|
GitUpstreamStatus,
|
|
GitStatusResult,
|
|
MarkdownDocument,
|
|
SearchOptions,
|
|
SearchResult,
|
|
Repo,
|
|
TuiAgent
|
|
} from '../../shared/types'
|
|
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
|
|
import {
|
|
buildRgArgs,
|
|
createAccumulator,
|
|
DEFAULT_SEARCH_MAX_RESULTS,
|
|
finalize,
|
|
ingestRgJsonLine,
|
|
SEARCH_TIMEOUT_MS
|
|
} from '../../shared/text-search'
|
|
import {
|
|
getStatus,
|
|
abortMerge,
|
|
abortRebase,
|
|
detectConflictOperation,
|
|
getDiff,
|
|
commitChanges,
|
|
stageFile,
|
|
unstageFile,
|
|
bulkStageFiles,
|
|
bulkUnstageFiles,
|
|
bulkDiscardChanges,
|
|
discardChanges,
|
|
getStagedCommitContext,
|
|
getBranchCompare,
|
|
getBranchDiff,
|
|
getCommitCompare,
|
|
getCommitDiff
|
|
} from '../git/status'
|
|
import { getHistory } from '../git/history'
|
|
import {
|
|
cancelGenerateCommitMessageLocal,
|
|
cancelGeneratePullRequestFieldsLocal,
|
|
discoverCommitMessageModelsLocal,
|
|
discoverCommitMessageModelsRemote,
|
|
generateCommitMessageFromContext,
|
|
generatePullRequestFieldsFromContext,
|
|
resolveCommitMessageSettings,
|
|
type DiscoverCommitMessageModelsResult,
|
|
type CommitMessageGenerationTarget,
|
|
type GenerateCommitMessageResult,
|
|
type GeneratePullRequestFieldsResult
|
|
} from '../text-generation/commit-message-text-generation'
|
|
import { getPullRequestDraftContext } from '../text-generation/pull-request-context'
|
|
import { getUpstreamStatus } from '../git/upstream'
|
|
import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote'
|
|
import { gitSyncForkDefaultBranch } from '../git/fork-sync'
|
|
import { validateGitForkSyncExpectedUpstream } from '../../shared/git-fork-sync'
|
|
import { checkIgnoredPaths } from '../git/check-ignored-paths'
|
|
import {
|
|
appendFolderToGitignore,
|
|
findKnownHugeFolderPathsToIgnore
|
|
} from '../git/huge-folder-ignore'
|
|
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
|
|
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
|
|
import type { HostedReviewProvider } from '../../shared/hosted-review'
|
|
import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai'
|
|
import { validateGitPushTarget } from '../git/push-target-validation'
|
|
import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo'
|
|
import {
|
|
resolveAuthorizedPath,
|
|
resolveRegisteredWorktreePath,
|
|
validateGitRelativeFilePath,
|
|
isENOENT,
|
|
authorizeExternalPath
|
|
} from './filesystem-auth'
|
|
import { listQuickOpenFiles } from './filesystem-list-files'
|
|
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
|
|
import { searchWithGitGrep } from './filesystem-search-git'
|
|
import { getLocalGitOptionsForRegisteredWorktree } from './local-worktree-runtime-options'
|
|
import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents'
|
|
import { checkRgAvailable } from './rg-availability'
|
|
import {
|
|
getSshFilesystemProvider,
|
|
requireSshFilesystemProvider
|
|
} from '../providers/ssh-filesystem-dispatch'
|
|
import {
|
|
getSshGitProvider,
|
|
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
|
|
} from '../providers/ssh-git-dispatch'
|
|
import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template'
|
|
import {
|
|
prepareLocalCommitMessageAgentEnv,
|
|
type CommitMessageAgentRuntimeTarget,
|
|
type CommitMessageAgentEnvironmentResolvers
|
|
} from '../text-generation/commit-message-agent-environment'
|
|
import { listRepoWorktrees } from '../repo-worktrees'
|
|
import { splitWorktreeId } from '../../shared/worktree-id'
|
|
import { getRuntimePathBasename } from '../../shared/cross-platform-path'
|
|
import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
|
|
|
// Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes
|
|
// ordinary JSON/log files inaccessible before the editor can degrade features.
|
|
const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB
|
|
const BINARY_PROBE_BYTES = 8192
|
|
const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/
|
|
// Why: previewable binaries (PDFs, images) are rendered by the viewer as
|
|
// base64 blobs, not parsed as text — 5MB is tight for real-world PDFs, and
|
|
// raising this cap only affects binary preview, not text/search paths.
|
|
// The relay (SSH) uses a smaller 10MB cap because its JSON-RPC frames are
|
|
// bounded by MAX_MESSAGE_SIZE = 16MB; the local IPC path has no such limit,
|
|
// so 50MB covers real-world PDFs (specs, datasheets, image-heavy contracts).
|
|
// See src/relay/fs-handler-utils.ts for the remote-side reasoning.
|
|
const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB
|
|
const PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
'.webp': 'image/webp',
|
|
'.bmp': 'image/bmp',
|
|
'.ico': 'image/x-icon',
|
|
'.pdf': 'application/pdf'
|
|
}
|
|
const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i
|
|
const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*'])
|
|
|
|
type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string }
|
|
|
|
function validateRequiredString(value: unknown, label: string): string {
|
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
throw new Error(`${label} is required`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function sanitizeSaveDialogFilename(remoteBasename: string): string {
|
|
const sanitized = Array.from(remoteBasename, (char) =>
|
|
char.charCodeAt(0) < 32 || LOCAL_FILENAME_REPLACEMENT_CHARS.has(char) ? '_' : char
|
|
)
|
|
.join('')
|
|
.replace(/[. ]+$/g, '')
|
|
if (!sanitized || WINDOWS_RESERVED_LOCAL_BASENAME.test(sanitized)) {
|
|
return 'download'
|
|
}
|
|
return sanitized
|
|
}
|
|
|
|
function createSiblingTransferPath(destinationPath: string, suffix: string): string {
|
|
return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`)
|
|
}
|
|
|
|
async function cleanupLocalTransferPath(filePath: string | null): Promise<void> {
|
|
if (!filePath) {
|
|
return
|
|
}
|
|
await rm(filePath, { force: true }).catch(() => {})
|
|
}
|
|
|
|
async function inspectDownloadDestination(destinationPath: string): Promise<{ existed: boolean }> {
|
|
try {
|
|
const destinationStat = await stat(destinationPath)
|
|
if (destinationStat.isDirectory()) {
|
|
throw new Error('Cannot download to a directory')
|
|
}
|
|
return { existed: true }
|
|
} catch (error) {
|
|
if (isENOENT(error)) {
|
|
return { existed: false }
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function assertDestinationStillUnclaimed(destinationPath: string): Promise<void> {
|
|
try {
|
|
await stat(destinationPath)
|
|
} catch (error) {
|
|
if (isENOENT(error)) {
|
|
return
|
|
}
|
|
throw error
|
|
}
|
|
throw new Error('Destination file appeared before download completed')
|
|
}
|
|
|
|
async function promoteDownloadedFile(
|
|
tempPath: string,
|
|
destinationPath: string,
|
|
destinationExisted: boolean
|
|
): Promise<void> {
|
|
if (!destinationExisted) {
|
|
await assertDestinationStillUnclaimed(destinationPath)
|
|
await rename(tempPath, destinationPath)
|
|
return
|
|
}
|
|
|
|
const backupPath = createSiblingTransferPath(destinationPath, 'backup')
|
|
let backupCreated = false
|
|
try {
|
|
await rename(destinationPath, backupPath)
|
|
backupCreated = true
|
|
await rename(tempPath, destinationPath)
|
|
await cleanupLocalTransferPath(backupPath)
|
|
} catch (error) {
|
|
if (backupCreated) {
|
|
await rename(backupPath, destinationPath).catch(() => {})
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function comparableLocalPath(value: string): string {
|
|
const normalized = resolve(value)
|
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
|
|
}
|
|
|
|
function getCandidateLocalWorktreePaths(
|
|
worktreePath: string,
|
|
resolvedWorktreePath: string
|
|
): Set<string> {
|
|
return new Set([worktreePath, resolvedWorktreePath].map(comparableLocalPath))
|
|
}
|
|
|
|
function hasRegisteredWorktreeMetaForRepo(
|
|
store: Store,
|
|
repoId: string,
|
|
candidatePaths: Set<string>
|
|
): boolean {
|
|
for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) {
|
|
const parsed = splitWorktreeId(worktreeId)
|
|
if (parsed?.repoId === repoId && candidatePaths.has(comparableLocalPath(parsed.worktreePath))) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
function comparableRemotePath(value: string): string {
|
|
return value.replace(/[/\\]+$/g, '')
|
|
}
|
|
|
|
function hasRegisteredRemoteWorktreeMetaForRepo(
|
|
store: Store,
|
|
repoId: string,
|
|
worktreePath: string
|
|
): boolean {
|
|
const comparableWorktreePath = comparableRemotePath(worktreePath)
|
|
for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) {
|
|
const parsed = splitWorktreeId(worktreeId)
|
|
if (
|
|
parsed?.repoId === repoId &&
|
|
comparableRemotePath(parsed.worktreePath) === comparableWorktreePath
|
|
) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function localRepoOwnsWorktree(
|
|
store: Store,
|
|
repo: Repo,
|
|
worktreePath: string
|
|
): Promise<boolean> {
|
|
let resolvedWorktreePath: string
|
|
try {
|
|
resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store)
|
|
} catch {
|
|
return false
|
|
}
|
|
const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath)
|
|
if (candidatePaths.has(comparableLocalPath(repo.path))) {
|
|
return true
|
|
}
|
|
if (hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths)) {
|
|
return true
|
|
}
|
|
try {
|
|
const worktrees = await listRepoWorktrees(repo)
|
|
return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path)))
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function remoteRepoOwnsWorktree(
|
|
store: Store,
|
|
repo: Repo,
|
|
worktreePath: string,
|
|
connectionId: string
|
|
): Promise<boolean> {
|
|
const comparableWorktreePath = comparableRemotePath(worktreePath)
|
|
if (comparableRemotePath(repo.path) === comparableWorktreePath) {
|
|
return true
|
|
}
|
|
const provider = getSshGitProvider(connectionId)
|
|
if (!provider) {
|
|
return hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath)
|
|
}
|
|
try {
|
|
const worktrees = await provider.listWorktrees(repo.path)
|
|
return worktrees.some(
|
|
(worktree) => comparableRemotePath(worktree.path) === comparableWorktreePath
|
|
)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function getRepoForSourceControlAi(
|
|
store: Store,
|
|
args: { repoId?: string; worktreePath: string; connectionId?: string }
|
|
): Promise<Repo | null> {
|
|
if (!args.repoId) {
|
|
return null
|
|
}
|
|
const repo = store.getRepo(args.repoId)
|
|
if (!repo) {
|
|
return null
|
|
}
|
|
if (args.connectionId) {
|
|
if (repo.connectionId !== args.connectionId) {
|
|
return null
|
|
}
|
|
// Why: a single SSH connection can host several repos; repo-scoped AI
|
|
// overrides only apply when the requested worktree belongs to that repo.
|
|
return (await remoteRepoOwnsWorktree(store, repo, args.worktreePath, args.connectionId))
|
|
? repo
|
|
: null
|
|
}
|
|
if (repo.connectionId) {
|
|
return null
|
|
}
|
|
// Why: renderer-supplied repoId is advisory; only apply repo overrides when
|
|
// the requested local worktree is known to belong to that repo.
|
|
return (await localRepoOwnsWorktree(store, repo, args.worktreePath)) ? repo : null
|
|
}
|
|
|
|
function getLocalAgentRuntimeTarget(
|
|
gitOptions: LocalProjectWorktreeGitOptions
|
|
): CommitMessageAgentRuntimeTarget {
|
|
return gitOptions.wslDistro
|
|
? { runtime: 'wsl', wslDistro: gitOptions.wslDistro }
|
|
: { runtime: 'host' }
|
|
}
|
|
|
|
function getLocalTextGenerationTarget(
|
|
worktreePath: string,
|
|
gitOptions: LocalProjectWorktreeGitOptions,
|
|
env?: NodeJS.ProcessEnv
|
|
): Extract<CommitMessageGenerationTarget, { kind: 'local' }> {
|
|
return {
|
|
kind: 'local',
|
|
cwd: worktreePath,
|
|
...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}),
|
|
...(env ? { env } : {})
|
|
}
|
|
}
|
|
|
|
function validateFullGitObjectId(value: string, label: string): string {
|
|
if (!FULL_GIT_OBJECT_ID_PATTERN.test(value)) {
|
|
throw new Error(`${label} must be a full git object id`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
/**
|
|
* Check if a buffer appears to be binary (contains null bytes in first 8KB).
|
|
*/
|
|
function isBinaryBuffer(buffer: Buffer): boolean {
|
|
const len = Math.min(buffer.length, 8192)
|
|
for (let i = 0; i < len; i++) {
|
|
if (buffer[i] === 0) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function isBinaryFilePrefix(filePath: string): Promise<boolean> {
|
|
const handle = await open(filePath, 'r')
|
|
try {
|
|
const probe = Buffer.alloc(BINARY_PROBE_BYTES)
|
|
const { bytesRead } = await handle.read(probe, 0, probe.length, 0)
|
|
return isBinaryBuffer(probe.subarray(0, bytesRead))
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
}
|
|
|
|
async function isDirectoryEntry(
|
|
dirPath: string,
|
|
entry: { name: string; isDirectory(): boolean; isSymbolicLink(): boolean },
|
|
_resolveEntryPath: (entryPath: string) => Promise<string>
|
|
): Promise<boolean> {
|
|
// Why: following a symlink just to decorate readDir can touch macOS
|
|
// TCC-protected app containers. Treat links as file-like until the user
|
|
// explicitly opens them.
|
|
void _resolveEntryPath
|
|
if (entry.isSymbolicLink()) {
|
|
void dirPath
|
|
return false
|
|
}
|
|
if (entry.isDirectory()) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
export function registerFilesystemHandlers(
|
|
store: Store,
|
|
commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers
|
|
): void {
|
|
const activeTextSearches = new Map<string, ChildProcess>()
|
|
|
|
// ─── Filesystem ─────────────────────────────────────────
|
|
ipcMain.handle(
|
|
'fs:readDir',
|
|
async (_event, args: { dirPath: string; connectionId?: string }): Promise<DirEntry[]> => {
|
|
if (args.connectionId) {
|
|
const provider = requireSshFilesystemProvider(args.connectionId)
|
|
return provider.readDir(args.dirPath)
|
|
}
|
|
const dirPath = await resolveAuthorizedPath(args.dirPath, store)
|
|
const entries = await readdir(dirPath, { withFileTypes: true })
|
|
const mapped = await Promise.all(
|
|
entries.map(async (entry) => ({
|
|
name: entry.name,
|
|
isDirectory: await isDirectoryEntry(dirPath, entry, (entryPath) =>
|
|
resolveAuthorizedPath(entryPath, store)
|
|
),
|
|
isSymlink: entry.isSymbolicLink()
|
|
}))
|
|
)
|
|
return mapped.sort((a, b) => {
|
|
if (a.isDirectory !== b.isDirectory) {
|
|
return a.isDirectory ? -1 : 1
|
|
}
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'fs:readFile',
|
|
async (
|
|
_event,
|
|
args: { filePath: string; connectionId?: string }
|
|
): Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> => {
|
|
if (args.connectionId) {
|
|
const provider = requireSshFilesystemProvider(args.connectionId)
|
|
return provider.readFile(args.filePath)
|
|
}
|
|
const filePath = await resolveAuthorizedPath(args.filePath, store)
|
|
const stats = await stat(filePath)
|
|
const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()]
|
|
const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE
|
|
if (stats.size > sizeLimit) {
|
|
throw new Error(
|
|
`File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit`
|
|
)
|
|
}
|
|
|
|
if (mimeType) {
|
|
const buffer = await readFile(filePath)
|
|
return {
|
|
content: buffer.toString('base64'),
|
|
isBinary: true,
|
|
// Why: the renderer/store contract already keys previewable binary
|
|
// rendering off `isImage`. Keep that legacy flag for PDFs too so the
|
|
// new preview path stays compatible with existing callers.
|
|
isImage: true,
|
|
mimeType
|
|
}
|
|
}
|
|
|
|
// Why: the text cap is intentionally larger than the old binary cap.
|
|
// Probe unknown large files first so archives do not get fully buffered
|
|
// just to discover they are not editable text.
|
|
if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) {
|
|
return { content: '', isBinary: true }
|
|
}
|
|
|
|
const buffer = await readFile(filePath)
|
|
if (isBinaryBuffer(buffer)) {
|
|
return { content: '', isBinary: true }
|
|
}
|
|
|
|
return { content: buffer.toString('utf-8'), isBinary: false }
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'fs:downloadFile',
|
|
async (
|
|
event,
|
|
args: { filePath?: string; connectionId?: string }
|
|
): Promise<DownloadFileResult> => {
|
|
const filePath = validateRequiredString(args?.filePath, 'filePath')
|
|
const connectionId = validateRequiredString(args?.connectionId, 'connectionId')
|
|
const provider = requireSshFilesystemProvider(connectionId)
|
|
const remoteStat = await provider.stat(filePath)
|
|
if (remoteStat.type === 'directory') {
|
|
throw new Error('Cannot download a directory')
|
|
}
|
|
if (!provider.downloadFile) {
|
|
throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.')
|
|
}
|
|
|
|
const remoteBasename = getRuntimePathBasename(filePath)
|
|
const defaultPath = sanitizeSaveDialogFilename(remoteBasename)
|
|
const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined
|
|
const dialogResult = parentWindow
|
|
? await dialog.showSaveDialog(parentWindow, { defaultPath })
|
|
: await dialog.showSaveDialog({ defaultPath })
|
|
if (dialogResult.canceled || !dialogResult.filePath) {
|
|
return { canceled: true }
|
|
}
|
|
|
|
const destinationPath = dialogResult.filePath
|
|
const { existed } = await inspectDownloadDestination(destinationPath)
|
|
const tempPath = createSiblingTransferPath(destinationPath, 'download')
|
|
let promoted = false
|
|
try {
|
|
await provider.downloadFile(filePath, tempPath)
|
|
await promoteDownloadedFile(tempPath, destinationPath, existed)
|
|
promoted = true
|
|
return { canceled: false, destinationPath }
|
|
} finally {
|
|
if (!promoted) {
|
|
await cleanupLocalTransferPath(tempPath)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'fs:listMarkdownDocuments',
|
|
async (
|
|
_event,
|
|
args: { rootPath: string; connectionId?: string }
|
|
): Promise<MarkdownDocument[]> => {
|
|
if (args.connectionId) {
|
|
const provider = requireSshFilesystemProvider(args.connectionId)
|
|
const relativePaths = await provider.listFiles(args.rootPath)
|
|
return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths)
|
|
}
|
|
|
|
const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store)
|
|
return listMarkdownDocuments(rootPath)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'fs:writeFile',
|
|
async (
|
|
_event,
|
|
args: { filePath: string; content: string; connectionId?: string }
|
|
): Promise<void> => {
|
|
if (args.connectionId) {
|
|
const provider = requireSshFilesystemProvider(args.connectionId)
|
|
return provider.writeFile(args.filePath, args.content)
|
|
}
|
|
const filePath = await resolveAuthorizedPath(args.filePath, store)
|
|
|
|
try {
|
|
const fileStats = await lstat(filePath)
|
|
if (fileStats.isDirectory()) {
|
|
throw new Error('Cannot write to a directory')
|
|
}
|
|
} catch (error) {
|
|
if (!isENOENT(error)) {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
await writeFile(filePath, args.content, 'utf-8')
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'fs:deletePath',
|
|
async (
|
|
_event,
|
|
args: { targetPath: string; connectionId?: string; recursive?: boolean }
|
|
): Promise<void> => {
|
|
if (args.connectionId) {
|
|
const provider = requireSshFilesystemProvider(args.connectionId)
|
|
return provider.deletePath(args.targetPath, args.recursive)
|
|
}
|
|
// Why: deleting must operate on the symlink itself, not its target.
|
|
// Following the link with realpath() would trash the real file — which
|
|
// could be another file inside the worktree, or a path outside all
|
|
// allowed roots that we would never be able to delete again.
|
|
const targetPath = await resolveAuthorizedPath(args.targetPath, store, {
|
|
preserveSymlink: true
|
|
})
|
|
|
|
// Why: once auto-refresh exists, an external delete can race with a
|
|
// UI-initiated delete. Swallowing ENOENT keeps the action idempotent
|
|
// from the user's perspective (design §7.1).
|
|
try {
|
|
await shell.trashItem(targetPath)
|
|
} catch (error) {
|
|
if (isENOENT(error)) {
|
|
return
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
)
|
|
|
|
registerFilesystemMutationHandlers(store)
|
|
|
|
ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => {
|
|
authorizeExternalPath(args.targetPath)
|
|
})
|
|
|
|
ipcMain.handle(
|
|
'fs:stat',
|
|
async (
|
|
_event,
|
|
args: { filePath: string; connectionId?: string }
|
|
): Promise<{ size: number; isDirectory: boolean; mtime: number }> => {
|
|
if (args.connectionId) {
|
|
const provider = requireSshFilesystemProvider(args.connectionId)
|
|
const s = await provider.stat(args.filePath)
|
|
return { size: s.size, isDirectory: s.type === 'directory', mtime: s.mtime }
|
|
}
|
|
const filePath = await resolveAuthorizedPath(args.filePath, store)
|
|
const stats = await stat(filePath)
|
|
return {
|
|
size: stats.size,
|
|
isDirectory: stats.isDirectory(),
|
|
mtime: stats.mtimeMs
|
|
}
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'fs:pathExists',
|
|
async (_event, args: { filePath: string; connectionId?: string }): Promise<boolean> => {
|
|
try {
|
|
if (args.connectionId) {
|
|
const provider = requireSshFilesystemProvider(args.connectionId)
|
|
await provider.stat(args.filePath)
|
|
return true
|
|
}
|
|
const filePath = await resolveAuthorizedPath(args.filePath, store)
|
|
await stat(filePath)
|
|
return true
|
|
} catch (error) {
|
|
if (isENOENT(error)) {
|
|
return false
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
)
|
|
|
|
// ─── Search ────────────────────────────────────────────
|
|
ipcMain.handle(
|
|
'fs:search',
|
|
async (event, args: SearchOptions & { connectionId?: string }): Promise<SearchResult> => {
|
|
if (args.connectionId) {
|
|
const provider = requireSshFilesystemProvider(args.connectionId)
|
|
return provider.search(args)
|
|
}
|
|
const rootPath = await resolveAuthorizedPath(args.rootPath, store)
|
|
const localGitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.rootPath,
|
|
rootPath
|
|
)
|
|
const maxResults = Math.max(
|
|
1,
|
|
Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS)
|
|
)
|
|
const searchKey = `${event.sender.id}:${rootPath}`
|
|
|
|
// Why: checking rg availability upfront avoids a race condition where
|
|
// spawn('rg') emits 'close' before 'error' on some platforms, causing
|
|
// the handler to resolve with empty results before the git-grep
|
|
// fallback can run. The result is cached after the first check.
|
|
const rgAvailable = await checkRgAvailable(rootPath, localGitOptions.wslDistro)
|
|
if (!rgAvailable) {
|
|
return searchWithGitGrep(rootPath, args, maxResults, localGitOptions)
|
|
}
|
|
|
|
return new Promise((resolvePromise) => {
|
|
const rgArgs = buildRgArgs(args.query, rootPath, args)
|
|
|
|
// Why: search requests are fired on each query/options change. If the
|
|
// previous ripgrep process keeps running, it can continue streaming and
|
|
// parsing thousands of matches on the Electron main thread after the UI
|
|
// no longer cares about that result, which is exactly the freeze users
|
|
// experience in large repos.
|
|
activeTextSearches.get(searchKey)?.kill()
|
|
|
|
const acc = createAccumulator()
|
|
let stdoutBuffer = ''
|
|
let resolved = false
|
|
let child: ChildProcess | null = null
|
|
let killTimeout: ReturnType<typeof setTimeout>
|
|
|
|
// Why: WSL-routed rg emits Linux-native paths. UNC repos carry their
|
|
// distro in the path; Windows-path repos carry it in project runtime.
|
|
const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro
|
|
const transformAbsPath = wslDistroForOutput
|
|
? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p)
|
|
: undefined
|
|
|
|
const resolveOnce = (): void => {
|
|
if (resolved) {
|
|
return
|
|
}
|
|
resolved = true
|
|
if (activeTextSearches.get(searchKey) === child) {
|
|
activeTextSearches.delete(searchKey)
|
|
}
|
|
clearTimeout(killTimeout)
|
|
// Why: child.kill() is advisory. If rg ignores it, detach our
|
|
// closures so repeated local searches do not retain old scans.
|
|
child?.stdout?.off('data', handleStdoutData)
|
|
child?.stderr?.off('data', handleStderrData)
|
|
child?.off('error', handleError)
|
|
child?.off('close', handleClose)
|
|
resolvePromise(finalize(acc))
|
|
}
|
|
|
|
const processLine = (line: string): void => {
|
|
const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath)
|
|
if (verdict === 'stop') {
|
|
child?.kill()
|
|
}
|
|
}
|
|
|
|
const nextChild = wslAwareSpawn('rg', rgArgs, {
|
|
cwd: rootPath,
|
|
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
child = nextChild
|
|
activeTextSearches.set(searchKey, nextChild)
|
|
|
|
const handleStdoutData = (chunk: string): void => {
|
|
stdoutBuffer += chunk
|
|
const lines = stdoutBuffer.split('\n')
|
|
stdoutBuffer = lines.pop() ?? ''
|
|
for (const line of lines) {
|
|
processLine(line)
|
|
}
|
|
}
|
|
const handleStderrData = (): void => {
|
|
// Drain stderr so rg cannot block on a full pipe.
|
|
}
|
|
const handleError = (): void => {
|
|
resolveOnce()
|
|
}
|
|
const handleClose = (): void => {
|
|
if (stdoutBuffer) {
|
|
processLine(stdoutBuffer)
|
|
}
|
|
resolveOnce()
|
|
}
|
|
|
|
nextChild.stdout!.setEncoding('utf-8')
|
|
nextChild.stdout!.on('data', handleStdoutData)
|
|
nextChild.stderr!.on('data', handleStderrData)
|
|
nextChild.once('error', handleError)
|
|
nextChild.once('close', handleClose)
|
|
|
|
// Why: if the timeout fires, the child is killed and results are partial.
|
|
// We must mark them as truncated so the UI can indicate incomplete results.
|
|
killTimeout = setTimeout(() => {
|
|
acc.truncated = true
|
|
child?.kill()
|
|
resolveOnce()
|
|
}, SEARCH_TIMEOUT_MS)
|
|
})
|
|
}
|
|
)
|
|
|
|
// ─── List all files (for quick-open) ─────────────────────
|
|
ipcMain.handle(
|
|
'fs:listFiles',
|
|
async (
|
|
_event,
|
|
args: { rootPath: string; connectionId?: string; excludePaths?: string[] }
|
|
): Promise<string[]> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshFilesystemProvider(args.connectionId)
|
|
// Why: when the SSH connection is not yet established (cold start) or
|
|
// temporarily disconnected, return [] so quick-open shows "No matching
|
|
// files" instead of an error banner. The file list will repopulate when
|
|
// the user re-opens quick-open after the connection is restored.
|
|
if (!provider) {
|
|
return []
|
|
}
|
|
// Why: forward excludePaths through to the remote provider.
|
|
// Dropping it here would silently double-scan nested linked worktrees
|
|
// over SSH and contribute to timeout-induced partial results.
|
|
return provider.listFiles(args.rootPath, { excludePaths: args.excludePaths })
|
|
}
|
|
return listQuickOpenFiles(args.rootPath, store, args.excludePaths)
|
|
}
|
|
)
|
|
|
|
// ─── Git operations ─────────────────────────────────────
|
|
ipcMain.handle(
|
|
'git:status',
|
|
async (
|
|
_event,
|
|
args: {
|
|
worktreePath: string
|
|
connectionId?: string
|
|
includeIgnored?: boolean
|
|
bypassEffectiveUpstreamNegativeCache?: boolean
|
|
}
|
|
): Promise<GitStatusResult> => {
|
|
const options = {
|
|
includeIgnored: args.includeIgnored ?? false,
|
|
...(args.bypassEffectiveUpstreamNegativeCache === true
|
|
? { bypassEffectiveUpstreamNegativeCache: true }
|
|
: {})
|
|
}
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.getStatus(args.worktreePath, options)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
return getStatus(worktreePath, { ...options, ...gitOptions })
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:checkIgnored',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; paths: string[]; connectionId?: string }
|
|
): Promise<string[]> => {
|
|
if (args.connectionId) {
|
|
const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p))
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.checkIgnoredPaths(args.worktreePath, paths)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p))
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
return checkIgnoredPaths(worktreePath, paths, gitOptions)
|
|
}
|
|
)
|
|
|
|
// Why: when status hits the entry limit, the SCM view offers to .gitignore the
|
|
// folder that's flooding it. These two handlers back that flow. Local-only:
|
|
// the huge-untracked-folder case is a local-dev pathology, and routing a
|
|
// .gitignore write through the SSH provider isn't worth the surface here.
|
|
ipcMain.handle(
|
|
'git:findHugeFoldersToIgnore',
|
|
async (_event, args: { worktreePath: string }): Promise<string[]> => {
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
return findKnownHugeFolderPathsToIgnore(worktreePath, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:appendGitignore',
|
|
async (_event, args: { worktreePath: string; folderName: string }): Promise<boolean> => {
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
return appendFolderToGitignore(worktreePath, args.folderName)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:history',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; connectionId?: string } & GitHistoryOptions
|
|
): Promise<GitHistoryResult> => {
|
|
const options: GitHistoryOptions = { limit: args.limit, baseRef: args.baseRef }
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.getHistory(args.worktreePath, options)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
return getHistory(worktreePath, { ...options, ...gitOptions })
|
|
}
|
|
)
|
|
|
|
// Why: lightweight fs-only check for conflict operation state. Used to poll
|
|
// non-active worktrees so their "Rebasing"/"Merging" badges clear when the
|
|
// operation finishes, without running a full `git status`.
|
|
ipcMain.handle(
|
|
'git:conflictOperation',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; connectionId?: string }
|
|
): Promise<GitConflictOperation> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.detectConflictOperation(args.worktreePath)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
return detectConflictOperation(worktreePath)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:abortMerge',
|
|
async (_event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(`No git provider for connection "${args.connectionId}"`)
|
|
}
|
|
return provider.abortMerge(args.worktreePath)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
await abortMerge(worktreePath, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:abortRebase',
|
|
async (_event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(`No git provider for connection "${args.connectionId}"`)
|
|
}
|
|
return provider.abortRebase(args.worktreePath)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
await abortRebase(worktreePath, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:diff',
|
|
async (
|
|
_event,
|
|
args: {
|
|
worktreePath: string
|
|
filePath: string
|
|
staged: boolean
|
|
compareAgainstHead?: boolean
|
|
connectionId?: string
|
|
}
|
|
): Promise<GitDiffResult> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.getDiff(
|
|
args.worktreePath,
|
|
args.filePath,
|
|
args.staged,
|
|
args.compareAgainstHead
|
|
)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:commit',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; message: string; connectionId?: string }
|
|
): Promise<{ success: boolean; error?: string }> => {
|
|
// Why: validate at the IPC boundary so the renderer gets a clear error instead of an opaque execFile failure.
|
|
if (typeof args.message !== 'string' || args.message.trim().length === 0) {
|
|
throw new Error('Commit message is required')
|
|
}
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.commit(args.worktreePath, args.message)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
return commitChanges(worktreePath, args.message, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:generateCommitMessage',
|
|
async (
|
|
_event,
|
|
args: {
|
|
worktreePath: string
|
|
repoId?: string
|
|
connectionId?: string
|
|
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
|
|
sourceControlAi?: GlobalSettings['sourceControlAi']
|
|
agentCmdOverrides?: GlobalSettings['agentCmdOverrides']
|
|
}
|
|
): Promise<GenerateCommitMessageResult> => {
|
|
const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null)
|
|
const baseSettings = store.getSettings()
|
|
const requestSettings = {
|
|
...baseSettings,
|
|
...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}),
|
|
...(args.agentCmdOverrides !== undefined
|
|
? { agentCmdOverrides: args.agentCmdOverrides }
|
|
: {})
|
|
}
|
|
const resolvedSettings = args.sourceControlAiResolvedParams
|
|
? { ok: true as const, params: args.sourceControlAiResolvedParams }
|
|
: resolveCommitMessageSettings(
|
|
requestSettings,
|
|
discoveryHostKey,
|
|
'commitMessage',
|
|
await getRepoForSourceControlAi(store, args)
|
|
)
|
|
if (!resolvedSettings.ok) {
|
|
return { success: false, error: resolvedSettings.error }
|
|
}
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
return {
|
|
success: false,
|
|
error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
|
|
}
|
|
}
|
|
let context
|
|
try {
|
|
context = await provider.getStagedCommitContext(args.worktreePath)
|
|
} catch (error) {
|
|
console.error('[filesystem] Failed to read remote staged commit context:', error)
|
|
return {
|
|
success: false,
|
|
error: 'Failed to read staged changes.'
|
|
}
|
|
}
|
|
if (!context) {
|
|
return { success: false, error: 'No staged changes to summarize.' }
|
|
}
|
|
return generateCommitMessageFromContext(context, resolvedSettings.params, {
|
|
kind: 'remote',
|
|
cwd: args.worktreePath,
|
|
execute: (plan, cwd, timeoutMs, operation) =>
|
|
provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
|
|
missingBinaryLocation: 'remote PATH'
|
|
})
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
let context
|
|
try {
|
|
context = await getStagedCommitContext(worktreePath, gitOptions)
|
|
} catch (error) {
|
|
console.error('[filesystem] Failed to read staged commit context:', error)
|
|
return {
|
|
success: false,
|
|
error: 'Failed to read staged changes.'
|
|
}
|
|
}
|
|
if (!context) {
|
|
return { success: false, error: 'No staged changes to summarize.' }
|
|
}
|
|
const localEnv = await prepareLocalCommitMessageAgentEnv(
|
|
resolvedSettings.params.agentId,
|
|
commitMessageAgentEnv,
|
|
getLocalAgentRuntimeTarget(gitOptions)
|
|
)
|
|
if (!localEnv.ok) {
|
|
return { success: false, error: localEnv.error }
|
|
}
|
|
return generateCommitMessageFromContext(
|
|
context,
|
|
resolvedSettings.params,
|
|
getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env)
|
|
)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:cancelGenerateCommitMessage',
|
|
async (_event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
return
|
|
}
|
|
await provider.cancelGenerateCommitMessage(args.worktreePath, 'commit-message')
|
|
return
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
cancelGenerateCommitMessageLocal(worktreePath)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:discoverCommitMessageModels',
|
|
async (
|
|
_event,
|
|
args: { agentId: string; worktreePath?: string; connectionId?: string }
|
|
): Promise<DiscoverCommitMessageModelsResult> => {
|
|
const agentId = args.agentId
|
|
const agentCommandOverride = store.getSettings().agentCmdOverrides?.[agentId as TuiAgent]
|
|
if (args.connectionId) {
|
|
if (!args.worktreePath) {
|
|
return { success: false, error: 'Missing worktree path for remote model discovery.' }
|
|
}
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
return {
|
|
success: false,
|
|
error: `No git provider for connection "${args.connectionId}"`
|
|
}
|
|
}
|
|
return discoverCommitMessageModelsRemote(
|
|
agentId as TuiAgent,
|
|
args.worktreePath,
|
|
(plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs),
|
|
agentCommandOverride
|
|
)
|
|
}
|
|
let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' }
|
|
let localDiscoveryOptions: Parameters<typeof discoverCommitMessageModelsLocal>[3]
|
|
if (args.worktreePath) {
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
localRuntimeTarget = getLocalAgentRuntimeTarget(gitOptions)
|
|
localDiscoveryOptions = gitOptions.wslDistro
|
|
? { cwd: worktreePath, wslDistro: gitOptions.wslDistro }
|
|
: { cwd: worktreePath }
|
|
}
|
|
const localEnv = await prepareLocalCommitMessageAgentEnv(
|
|
agentId,
|
|
commitMessageAgentEnv,
|
|
localRuntimeTarget
|
|
)
|
|
if (!localEnv.ok) {
|
|
return { success: false, error: localEnv.error }
|
|
}
|
|
return localDiscoveryOptions
|
|
? discoverCommitMessageModelsLocal(
|
|
agentId as TuiAgent,
|
|
localEnv.env,
|
|
agentCommandOverride,
|
|
localDiscoveryOptions
|
|
)
|
|
: discoverCommitMessageModelsLocal(agentId as TuiAgent, localEnv.env, agentCommandOverride)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:generatePullRequestFields',
|
|
async (
|
|
_event,
|
|
args: {
|
|
worktreePath: string
|
|
repoId?: string
|
|
base: string
|
|
title: string
|
|
body: string
|
|
draft: boolean
|
|
provider?: HostedReviewProvider
|
|
useTemplate?: boolean
|
|
connectionId?: string
|
|
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
|
|
sourceControlAi?: GlobalSettings['sourceControlAi']
|
|
agentCmdOverrides?: GlobalSettings['agentCmdOverrides']
|
|
}
|
|
): Promise<GeneratePullRequestFieldsResult> => {
|
|
const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null)
|
|
const baseSettings = store.getSettings()
|
|
const requestSettings = {
|
|
...baseSettings,
|
|
...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}),
|
|
...(args.agentCmdOverrides !== undefined
|
|
? { agentCmdOverrides: args.agentCmdOverrides }
|
|
: {})
|
|
}
|
|
const resolvedSettings = args.sourceControlAiResolvedParams
|
|
? { ok: true as const, params: args.sourceControlAiResolvedParams }
|
|
: resolveCommitMessageSettings(
|
|
requestSettings,
|
|
discoveryHostKey,
|
|
'pullRequest',
|
|
await getRepoForSourceControlAi(store, args)
|
|
)
|
|
if (!resolvedSettings.ok) {
|
|
return { success: false, error: resolvedSettings.error }
|
|
}
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
return {
|
|
success: false,
|
|
error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
|
|
}
|
|
}
|
|
let context: Awaited<ReturnType<typeof getPullRequestDraftContext>>
|
|
try {
|
|
const currentBody = await resolveHostedReviewBodyForGeneration({
|
|
body: args.body,
|
|
repoPath: args.worktreePath,
|
|
connectionId: args.connectionId,
|
|
provider: args.provider,
|
|
useTemplate: args.useTemplate
|
|
})
|
|
context = await getPullRequestDraftContext(
|
|
(argv) => provider.exec(argv, args.worktreePath),
|
|
{
|
|
base: args.base,
|
|
currentTitle: args.title,
|
|
currentBody,
|
|
currentDraft: args.draft
|
|
}
|
|
)
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
error instanceof Error ? error.message : 'Failed to prepare branch for PR details.'
|
|
}
|
|
}
|
|
if (!context) {
|
|
return { success: false, error: 'No branch changes to summarize.' }
|
|
}
|
|
return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
|
|
kind: 'remote',
|
|
cwd: args.worktreePath,
|
|
execute: (plan, cwd, timeoutMs, operation) =>
|
|
provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
|
|
missingBinaryLocation: 'remote PATH'
|
|
})
|
|
}
|
|
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
let context: Awaited<ReturnType<typeof getPullRequestDraftContext>>
|
|
try {
|
|
const currentBody = await resolveHostedReviewBodyForGeneration({
|
|
body: args.body,
|
|
repoPath: worktreePath,
|
|
connectionId: args.connectionId,
|
|
provider: args.provider,
|
|
useTemplate: args.useTemplate
|
|
})
|
|
context = await getPullRequestDraftContext(
|
|
(argv, options) =>
|
|
gitExecFileAsync(argv, { cwd: worktreePath, ...gitOptions, ...options }),
|
|
{
|
|
base: args.base,
|
|
currentTitle: args.title,
|
|
currentBody,
|
|
currentDraft: args.draft
|
|
}
|
|
)
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to prepare branch for PR details.'
|
|
}
|
|
}
|
|
if (!context) {
|
|
return { success: false, error: 'No branch changes to summarize.' }
|
|
}
|
|
const localEnv = await prepareLocalCommitMessageAgentEnv(
|
|
resolvedSettings.params.agentId,
|
|
commitMessageAgentEnv,
|
|
getLocalAgentRuntimeTarget(gitOptions)
|
|
)
|
|
if (!localEnv.ok) {
|
|
return { success: false, error: localEnv.error }
|
|
}
|
|
return generatePullRequestFieldsFromContext(
|
|
context,
|
|
resolvedSettings.params,
|
|
getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env)
|
|
)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:cancelGeneratePullRequestFields',
|
|
async (_event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
return
|
|
}
|
|
await provider.cancelGenerateCommitMessage(args.worktreePath, 'pull-request-fields')
|
|
return
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
cancelGeneratePullRequestFieldsLocal(worktreePath)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:branchCompare',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; baseRef: string; connectionId?: string }
|
|
): Promise<GitBranchCompareResult> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.getBranchCompare(args.worktreePath, args.baseRef)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
return getBranchCompare(worktreePath, args.baseRef, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:commitCompare',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; commitId: string; connectionId?: string }
|
|
): Promise<GitCommitCompareResult> => {
|
|
const commitId = validateFullGitObjectId(args.commitId, 'commitId')
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.getCommitCompare(args.worktreePath, commitId)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
return getCommitCompare(worktreePath, commitId, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:upstreamStatus',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget }
|
|
): Promise<GitUpstreamStatus> => {
|
|
if (args.connectionId) {
|
|
if (args.pushTarget) {
|
|
assertGitPushTargetShape(args.pushTarget)
|
|
}
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.getUpstreamStatus(args.worktreePath, args.pushTarget)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
return getUpstreamStatus(worktreePath, args.pushTarget, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:fetch',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget }
|
|
): Promise<void> => {
|
|
if (args.connectionId) {
|
|
if (args.pushTarget) {
|
|
assertGitPushTargetShape(args.pushTarget)
|
|
}
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.fetchRemote(args.worktreePath, args.pushTarget)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
if (args.pushTarget) {
|
|
await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions)
|
|
}
|
|
await gitFetch(worktreePath, args.pushTarget, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:syncFork',
|
|
async (
|
|
_event,
|
|
args: {
|
|
worktreePath: string
|
|
connectionId?: string
|
|
expectedUpstream: GitForkSyncExpectedUpstream
|
|
}
|
|
): Promise<GitForkSyncResult> => {
|
|
const expectedUpstream = validateGitForkSyncExpectedUpstream(args.expectedUpstream, {
|
|
required: true
|
|
})
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:push',
|
|
async (
|
|
_event,
|
|
args: {
|
|
worktreePath: string
|
|
publish?: boolean
|
|
forceWithLease?: boolean
|
|
connectionId?: string
|
|
pushTarget?: GitPushTarget
|
|
}
|
|
): Promise<void> => {
|
|
// Why: coerce to strict boolean at the IPC boundary so a malformed
|
|
// renderer payload (e.g. string 'false') can't silently enable
|
|
// --set-upstream mode. Mirrors the relay handler in src/relay/git-handler.ts.
|
|
const publish = args.publish === true
|
|
if (args.connectionId) {
|
|
if (args.pushTarget) {
|
|
assertGitPushTargetShape(args.pushTarget)
|
|
}
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.pushBranch(args.worktreePath, publish, args.pushTarget, {
|
|
forceWithLease: args.forceWithLease === true
|
|
})
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
if (args.pushTarget) {
|
|
await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions)
|
|
}
|
|
await gitPush(worktreePath, publish, args.pushTarget, {
|
|
forceWithLease: args.forceWithLease === true,
|
|
...gitOptions
|
|
})
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:pull',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget }
|
|
): Promise<void> => {
|
|
if (args.connectionId) {
|
|
if (args.pushTarget) {
|
|
assertGitPushTargetShape(args.pushTarget)
|
|
}
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.pullBranch(args.worktreePath, args.pushTarget)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
if (args.pushTarget) {
|
|
await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions)
|
|
}
|
|
await gitPull(worktreePath, args.pushTarget, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:fastForward',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget }
|
|
): Promise<void> => {
|
|
if (args.connectionId) {
|
|
if (args.pushTarget) {
|
|
assertGitPushTargetShape(args.pushTarget)
|
|
}
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.fastForwardBranch(args.worktreePath, args.pushTarget)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
if (args.pushTarget) {
|
|
await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions)
|
|
}
|
|
await gitFastForward(worktreePath, args.pushTarget, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:rebaseFromBase',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; baseRef: string; connectionId?: string }
|
|
): Promise<void> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.rebaseFromBase(args.worktreePath, args.baseRef)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
await gitPullRebaseFromBase(worktreePath, args.baseRef, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:branchDiff',
|
|
async (
|
|
_event,
|
|
args: {
|
|
worktreePath: string
|
|
compare: {
|
|
baseRef: string
|
|
baseOid: string
|
|
headOid: string
|
|
mergeBase: string
|
|
}
|
|
filePath: string
|
|
oldPath?: string
|
|
connectionId?: string
|
|
}
|
|
): Promise<GitDiffResult> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
const results = await provider.getBranchDiff(args.worktreePath, args.compare.mergeBase, {
|
|
includePatch: true,
|
|
filePath: args.filePath,
|
|
oldPath: args.oldPath
|
|
})
|
|
return (
|
|
results[0] ?? {
|
|
kind: 'text',
|
|
originalContent: '',
|
|
modifiedContent: '',
|
|
originalIsBinary: false,
|
|
modifiedIsBinary: false
|
|
}
|
|
)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
|
|
const oldPath = args.oldPath
|
|
? validateGitRelativeFilePath(worktreePath, args.oldPath)
|
|
: undefined
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
return getBranchDiff(
|
|
worktreePath,
|
|
{
|
|
mergeBase: args.compare.mergeBase,
|
|
headOid: args.compare.headOid,
|
|
filePath,
|
|
oldPath
|
|
},
|
|
gitOptions
|
|
)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:commitDiff',
|
|
async (
|
|
_event,
|
|
args: {
|
|
worktreePath: string
|
|
commitOid: string
|
|
parentOid?: string | null
|
|
filePath: string
|
|
oldPath?: string
|
|
connectionId?: string
|
|
}
|
|
): Promise<GitDiffResult> => {
|
|
const commitOid = validateFullGitObjectId(args.commitOid, 'commitOid')
|
|
const parentOid = args.parentOid ? validateFullGitObjectId(args.parentOid, 'parentOid') : null
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.getCommitDiff(args.worktreePath, {
|
|
commitOid,
|
|
parentOid,
|
|
filePath: args.filePath,
|
|
oldPath: args.oldPath
|
|
})
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
|
|
const oldPath = args.oldPath
|
|
? validateGitRelativeFilePath(worktreePath, args.oldPath)
|
|
: undefined
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
return getCommitDiff(
|
|
worktreePath,
|
|
{
|
|
commitOid,
|
|
parentOid,
|
|
filePath,
|
|
oldPath
|
|
},
|
|
gitOptions
|
|
)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:stage',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; filePath: string; connectionId?: string }
|
|
): Promise<void> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.stageFile(args.worktreePath, args.filePath)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
await stageFile(worktreePath, filePath, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:unstage',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; filePath: string; connectionId?: string }
|
|
): Promise<void> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.unstageFile(args.worktreePath, args.filePath)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
await unstageFile(worktreePath, filePath, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:discard',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; filePath: string; connectionId?: string }
|
|
): Promise<void> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.discardChanges(args.worktreePath, args.filePath)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
await discardChanges(worktreePath, filePath, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:bulkDiscard',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; filePaths: string[]; connectionId?: string }
|
|
): Promise<void> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.bulkDiscardChanges(args.worktreePath, args.filePaths)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p))
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
await bulkDiscardChanges(worktreePath, filePaths, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:bulkStage',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; filePaths: string[]; connectionId?: string }
|
|
): Promise<void> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.bulkStageFiles(args.worktreePath, args.filePaths)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p))
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
await bulkStageFiles(worktreePath, filePaths, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:bulkUnstage',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; filePaths: string[]; connectionId?: string }
|
|
): Promise<void> => {
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.bulkUnstageFiles(args.worktreePath, args.filePaths)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p))
|
|
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
|
|
store,
|
|
args.worktreePath,
|
|
worktreePath
|
|
)
|
|
await bulkUnstageFiles(worktreePath, filePaths, gitOptions)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:remoteFileUrl',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; relativePath: string; line: number; connectionId?: string }
|
|
): Promise<string | null> => {
|
|
// Why: remote repos can't read relay-side .git/config locally. Delegate
|
|
// URL construction to the SSH provider, which can fetch remote metadata.
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
return getRemoteFileUrl(worktreePath, args.relativePath, args.line)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'git:remoteCommitUrl',
|
|
async (
|
|
_event,
|
|
args: { worktreePath: string; sha: string; connectionId?: string }
|
|
): Promise<string | null> => {
|
|
const sha = validateFullGitObjectId(args.sha, 'sha')
|
|
// Why: remote repos can't read relay-side .git/config locally. Delegate
|
|
// URL construction to the SSH provider, which can fetch remote metadata.
|
|
if (args.connectionId) {
|
|
const provider = getSshGitProvider(args.connectionId)
|
|
if (!provider) {
|
|
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
|
}
|
|
return provider.getRemoteCommitUrl(args.worktreePath, sha)
|
|
}
|
|
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
|
return getRemoteCommitUrl(worktreePath, sha)
|
|
}
|
|
)
|
|
}
|