diff --git a/src/__tests__/renderer/hooks/useWorktreeValidation.test.ts b/src/__tests__/renderer/hooks/useWorktreeValidation.test.ts index 9e44b8e1..64cf1a13 100644 --- a/src/__tests__/renderer/hooks/useWorktreeValidation.test.ts +++ b/src/__tests__/renderer/hooks/useWorktreeValidation.test.ts @@ -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 () => { diff --git a/src/__tests__/renderer/services/git.test.ts b/src/__tests__/renderer/services/git.test.ts index b7f49ed0..aa64efe7 100644 --- a/src/__tests__/renderer/services/git.test.ts +++ b/src/__tests__/renderer/services/git.test.ts @@ -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 () => { diff --git a/src/main/ipc/handlers/git.ts b/src/main/ipc/handlers/git.ts index eea41d65..9f7488e9 100644 --- a/src/main/ipc/handlers/git.ts +++ b/src/main/ipc/handlers/git.ts @@ -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'; diff --git a/src/renderer/hooks/batch/useWorktreeValidation.ts b/src/renderer/hooks/batch/useWorktreeValidation.ts index b806e8a4..d4d0017b 100644 --- a/src/renderer/hooks/batch/useWorktreeValidation.ts +++ b/src/renderer/hooks/batch/useWorktreeValidation.ts @@ -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 diff --git a/src/renderer/hooks/git/useGitStatusPolling.ts b/src/renderer/hooks/git/useGitStatusPolling.ts index 624efff2..28014dca 100644 --- a/src/renderer/hooks/git/useGitStatusPolling.ts +++ b/src/renderer/hooks/git/useGitStatusPolling.ts @@ -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 diff --git a/src/renderer/services/git.ts b/src/renderer/services/git.ts index 552eac7e..89c8a650 100644 --- a/src/renderer/services/git.ts +++ b/src/renderer/services/git.ts @@ -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 { + async isRepo(cwd: string, sshRemoteId?: string): Promise { 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 { + async getStatus(cwd: string, sshRemoteId?: string): Promise { 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 { + async getDiff(cwd: string, files?: string[], sshRemoteId?: string): Promise { 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 { + async getNumstat(cwd: string, sshRemoteId?: string): Promise { 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 { + async getRemoteBrowserUrl(cwd: string, sshRemoteId?: string): Promise { 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 { + async getBranches(cwd: string, sshRemoteId?: string): Promise { 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 { + async getTags(cwd: string, sshRemoteId?: string): Promise { 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',