MAESTRO: Add SSH remote support to basic git IPC handlers

Wire up SSH context to git:status, git:diff, git:isRepo, git:numstat,
git:branch, git:branches, git:tags, git:remote, and git:info handlers.
These operations now support executing on remote hosts via SSH when
an sshRemoteId parameter is provided.

Changes:
- Add sshRemoteId parameter to git handlers in git.ts
- Import execGitRemote from remote-git.ts for SSH execution
- Update preload.ts with new function signatures
- Update global.d.ts with TypeScript types
- Update gitService in renderer with SSH support
- Wire SSH context to useGitStatusPolling hook
- Wire SSH context to useWorktreeValidation hook
- Update tests to expect new parameter signatures
This commit is contained in:
Pedram Amini
2025-12-30 03:18:49 -06:00
parent c8d0388d15
commit 6433d654fc
6 changed files with 93 additions and 31 deletions

View File

@@ -198,7 +198,8 @@ describe('useWorktreeValidation', () => {
expect(result.current.validation.branchMismatch).toBe(true);
expect(result.current.validation.hasUncommittedChanges).toBe(true);
expect(mockGit.status).toHaveBeenCalledWith('/path/to/worktree');
// sshRemoteId is undefined when not provided in deps
expect(mockGit.status).toHaveBeenCalledWith('/path/to/worktree', undefined);
});
it('does not check uncommitted changes when repos differ', async () => {

View File

@@ -40,7 +40,7 @@ describe('gitService', () => {
const result = await gitService.isRepo('/path/to/repo');
expect(result).toBe(true);
expect(mockGit.isRepo).toHaveBeenCalledWith('/path/to/repo');
expect(mockGit.isRepo).toHaveBeenCalledWith('/path/to/repo', undefined);
});
test('returns false when directory is not a git repository', async () => {
@@ -49,7 +49,16 @@ describe('gitService', () => {
const result = await gitService.isRepo('/path/to/non-repo');
expect(result).toBe(false);
expect(mockGit.isRepo).toHaveBeenCalledWith('/path/to/non-repo');
expect(mockGit.isRepo).toHaveBeenCalledWith('/path/to/non-repo', undefined);
});
test('passes sshRemoteId for remote repository check', async () => {
mockGit.isRepo.mockResolvedValue(true);
const result = await gitService.isRepo('/remote/path', 'ssh-remote-123');
expect(result).toBe(true);
expect(mockGit.isRepo).toHaveBeenCalledWith('/remote/path', 'ssh-remote-123');
});
test('returns false and logs error when IPC call fails', async () => {
@@ -167,7 +176,8 @@ UU both-changed-in-merge.ts`;
const result = await gitService.getDiff('/path/to/repo');
expect(result.diff).toBe(diffOutput);
expect(mockGit.diff).toHaveBeenCalledWith('/path/to/repo');
// When no files are specified, sshRemoteId defaults to undefined
expect(mockGit.diff).toHaveBeenCalledWith('/path/to/repo', undefined, undefined);
});
test('returns diff for all files when empty array specified', async () => {
@@ -177,7 +187,8 @@ UU both-changed-in-merge.ts`;
const result = await gitService.getDiff('/path/to/repo', []);
expect(result.diff).toBe(diffOutput);
expect(mockGit.diff).toHaveBeenCalledWith('/path/to/repo');
// sshRemoteId defaults to undefined
expect(mockGit.diff).toHaveBeenCalledWith('/path/to/repo', undefined, undefined);
});
test('returns diff for specific files when files array provided', async () => {
@@ -190,8 +201,18 @@ UU both-changed-in-merge.ts`;
const result = await gitService.getDiff('/path/to/repo', ['file1.ts', 'file2.ts']);
expect(result).toEqual({ diff: `${diffOutput1}\n${diffOutput2}` });
expect(mockGit.diff).toHaveBeenNthCalledWith(1, '/path/to/repo', 'file1.ts');
expect(mockGit.diff).toHaveBeenNthCalledWith(2, '/path/to/repo', 'file2.ts');
// sshRemoteId defaults to undefined
expect(mockGit.diff).toHaveBeenNthCalledWith(1, '/path/to/repo', 'file1.ts', undefined);
expect(mockGit.diff).toHaveBeenNthCalledWith(2, '/path/to/repo', 'file2.ts', undefined);
});
test('passes sshRemoteId for remote diff', async () => {
mockGit.diff.mockResolvedValue({ stdout: 'remote diff' });
const result = await gitService.getDiff('/remote/path', undefined, 'ssh-remote-123');
expect(result.diff).toBe('remote diff');
expect(mockGit.diff).toHaveBeenCalledWith('/remote/path', undefined, 'ssh-remote-123');
});
test('returns empty diff string on error', async () => {
@@ -375,7 +396,16 @@ invalid_line`;
const result = await gitService.getBranches('/path/to/repo');
expect(result).toEqual(['main', 'develop', 'feature/test']);
expect(mockGit.branches).toHaveBeenCalledWith('/path/to/repo');
expect(mockGit.branches).toHaveBeenCalledWith('/path/to/repo', undefined);
});
test('passes sshRemoteId for remote branches', async () => {
mockGit.branches.mockResolvedValue({ branches: ['main'] });
const result = await gitService.getBranches('/remote/path', 'ssh-remote-123');
expect(result).toEqual(['main']);
expect(mockGit.branches).toHaveBeenCalledWith('/remote/path', 'ssh-remote-123');
});
test('returns empty array when result.branches is undefined', async () => {
@@ -403,7 +433,16 @@ invalid_line`;
const result = await gitService.getTags('/path/to/repo');
expect(result).toEqual(['v1.0.0', 'v1.1.0', 'v2.0.0']);
expect(mockGit.tags).toHaveBeenCalledWith('/path/to/repo');
expect(mockGit.tags).toHaveBeenCalledWith('/path/to/repo', undefined);
});
test('passes sshRemoteId for remote tags', async () => {
mockGit.tags.mockResolvedValue({ tags: ['v1.0.0'] });
const result = await gitService.getTags('/remote/path', 'ssh-remote-123');
expect(result).toEqual(['v1.0.0']);
expect(mockGit.tags).toHaveBeenCalledWith('/remote/path', 'ssh-remote-123');
});
test('returns empty array when result.tags is undefined', async () => {

View File

@@ -2,7 +2,6 @@ import { ipcMain, BrowserWindow } from 'electron';
import fs from 'fs/promises';
import path from 'path';
import chokidar, { FSWatcher } from 'chokidar';
import Store from 'electron-store';
import { execFileNoThrow } from '../../utils/execFile';
import { execGit } from '../../utils/remote-git';
import { logger } from '../../utils/logger';

View File

@@ -159,7 +159,8 @@ export function useWorktreeValidation({
if (branchMismatch && sameRepo) {
try {
// Use git status to check for uncommitted changes in the worktree
const statusResult = await window.maestro.git.status(worktreePath);
// Pass sshRemoteId to support remote worktree validation
const statusResult = await window.maestro.git.status(worktreePath, sshRemoteId);
hasChanges = hasUncommittedChanges(statusResult.stdout);
} catch {
// If we can't check, assume no uncommitted changes

View File

@@ -167,9 +167,12 @@ export function useGitStatusPolling(
const isActiveSession = session.id === currentActiveSessionId;
// Get SSH remote ID from session for remote git operations
const sshRemoteId = session.sshRemoteId;
// For non-active sessions, just get basic status (file count)
if (!isActiveSession) {
const status = await gitService.getStatus(cwd);
const status = await gitService.getStatus(cwd, sshRemoteId);
const statusData: GitStatusData = {
fileCount: status.files.length,
branch: status.branch,
@@ -187,9 +190,9 @@ export function useGitStatusPolling(
// Use git:info for branch/remote/ahead/behind (single IPC call, 4 parallel git commands)
// Plus get detailed file changes with numstat
const [gitInfo, status, numstat] = await Promise.all([
window.maestro.git.info(cwd),
gitService.getStatus(cwd),
gitService.getNumstat(cwd),
window.maestro.git.info(cwd, sshRemoteId),
gitService.getStatus(cwd, sshRemoteId),
gitService.getNumstat(cwd, sshRemoteId),
]);
// Create a map of path -> numstat data

View File

@@ -26,13 +26,19 @@ export interface GitNumstat {
}>;
}
/**
* All git service methods support SSH remote execution via optional sshRemoteId parameter.
* When sshRemoteId is provided, operations execute on the remote host via SSH.
*/
export const gitService = {
/**
* Check if a directory is a git repository
* @param cwd Working directory path
* @param sshRemoteId Optional SSH remote ID for remote execution
*/
async isRepo(cwd: string): Promise<boolean> {
async isRepo(cwd: string, sshRemoteId?: string): Promise<boolean> {
return createIpcMethod({
call: () => window.maestro.git.isRepo(cwd),
call: () => window.maestro.git.isRepo(cwd, sshRemoteId),
errorContext: 'Git isRepo',
defaultValue: false,
});
@@ -40,13 +46,15 @@ export const gitService = {
/**
* Get git status (porcelain format) and current branch
* @param cwd Working directory path
* @param sshRemoteId Optional SSH remote ID for remote execution
*/
async getStatus(cwd: string): Promise<GitStatus> {
async getStatus(cwd: string, sshRemoteId?: string): Promise<GitStatus> {
return createIpcMethod({
call: async () => {
const [statusResult, branchResult] = await Promise.all([
window.maestro.git.status(cwd),
window.maestro.git.branch(cwd),
window.maestro.git.status(cwd, sshRemoteId),
window.maestro.git.branch(cwd, sshRemoteId),
]);
const files = parseGitStatusPorcelain(statusResult.stdout || '');
@@ -61,18 +69,21 @@ export const gitService = {
/**
* Get git diff for specific files or all changes
* @param cwd Working directory path
* @param files Optional list of files to get diff for
* @param sshRemoteId Optional SSH remote ID for remote execution
*/
async getDiff(cwd: string, files?: string[]): Promise<GitDiff> {
async getDiff(cwd: string, files?: string[], sshRemoteId?: string): Promise<GitDiff> {
return createIpcMethod({
call: async () => {
// If no files specified, get full diff
if (!files || files.length === 0) {
const result = await window.maestro.git.diff(cwd);
const result = await window.maestro.git.diff(cwd, undefined, sshRemoteId);
return { diff: result.stdout };
}
// Otherwise get diff for specific files
const results = await Promise.all(
files.map(file => window.maestro.git.diff(cwd, file))
files.map(file => window.maestro.git.diff(cwd, file, sshRemoteId))
);
return { diff: results.map(result => result.stdout).join('\n') };
},
@@ -83,11 +94,13 @@ export const gitService = {
/**
* Get line-level statistics for all changes
* @param cwd Working directory path
* @param sshRemoteId Optional SSH remote ID for remote execution
*/
async getNumstat(cwd: string): Promise<GitNumstat> {
async getNumstat(cwd: string, sshRemoteId?: string): Promise<GitNumstat> {
return createIpcMethod({
call: async () => {
const result = await window.maestro.git.numstat(cwd);
const result = await window.maestro.git.numstat(cwd, sshRemoteId);
const files = parseGitNumstat(result.stdout || '');
return { files };
},
@@ -99,11 +112,13 @@ export const gitService = {
/**
* Get the browser-friendly URL for the remote repository
* Returns null if no remote or URL cannot be parsed
* @param cwd Working directory path
* @param sshRemoteId Optional SSH remote ID for remote execution
*/
async getRemoteBrowserUrl(cwd: string): Promise<string | null> {
async getRemoteBrowserUrl(cwd: string, sshRemoteId?: string): Promise<string | null> {
return createIpcMethod({
call: async () => {
const result = await window.maestro.git.remote(cwd);
const result = await window.maestro.git.remote(cwd, sshRemoteId);
return result.stdout ? remoteUrlToBrowserUrl(result.stdout) : null;
},
errorContext: 'Git remote',
@@ -113,11 +128,13 @@ export const gitService = {
/**
* Get all branches (local and remote, deduplicated)
* @param cwd Working directory path
* @param sshRemoteId Optional SSH remote ID for remote execution
*/
async getBranches(cwd: string): Promise<string[]> {
async getBranches(cwd: string, sshRemoteId?: string): Promise<string[]> {
return createIpcMethod({
call: async () => {
const result = await window.maestro.git.branches(cwd);
const result = await window.maestro.git.branches(cwd, sshRemoteId);
return result.branches || [];
},
errorContext: 'Git branches',
@@ -127,11 +144,13 @@ export const gitService = {
/**
* Get all tags
* @param cwd Working directory path
* @param sshRemoteId Optional SSH remote ID for remote execution
*/
async getTags(cwd: string): Promise<string[]> {
async getTags(cwd: string, sshRemoteId?: string): Promise<string[]> {
return createIpcMethod({
call: async () => {
const result = await window.maestro.git.tags(cwd);
const result = await window.maestro.git.tags(cwd, sshRemoteId);
return result.tags || [];
},
errorContext: 'Git tags',