mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user