diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 7943fcdf9c..db03bea4b8 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -19,6 +19,7 @@ import type { MessageContent } from '../../shared/types/message' import * as fs from 'fs' import * as path from 'path' import { spawn } from 'child_process' +import { getGitBranch } from '../utils/git' /** * Promisified spawn that waits for the process to complete @@ -53,14 +54,17 @@ function isValidPath(inputPath: string): boolean { } /** - * Adds directoryExists field to a session by checking if workingDirectory exists + * Adds directoryExists and gitBranch fields to a session */ -function withDirectoryExists( +async function withRuntimeInfo( session: T -): T & { directoryExists: boolean } { +): Promise { + const directoryExists = fs.existsSync(session.workingDirectory) + const gitBranch = directoryExists ? await getGitBranch(session.workingDirectory) : undefined return { ...session, - directoryExists: fs.existsSync(session.workingDirectory) + directoryExists, + gitBranch } } @@ -131,7 +135,7 @@ export function registerIPCHandlers(conductor: Conductor): void { throw new Error(`Unknown agent: ${agentId}`) } const session = await conductor.createSession(workingDirectory, config) - return withDirectoryExists(session) + return withRuntimeInfo(session) } catch (err) { throw new Error(extractErrorMessage(err)) } @@ -140,7 +144,8 @@ export function registerIPCHandlers(conductor: Conductor): void { ipcMain.handle(IPC_CHANNELS.SESSION_LIST, async (_event, options?: ListSessionsOptions) => { const sessions = await conductor.listSessions(options) - return sessions.map(withDirectoryExists) + // Fetch git branch info in parallel for all sessions + return Promise.all(sessions.map(withRuntimeInfo)) }) ipcMain.handle(IPC_CHANNELS.SESSION_GET, async (_event, sessionId: string) => { @@ -149,17 +154,17 @@ export function registerIPCHandlers(conductor: Conductor): void { ipcMain.handle(IPC_CHANNELS.SESSION_LOAD, async (_event, sessionId: string) => { const session = await conductor.loadSession(sessionId) - return withDirectoryExists(session) + return withRuntimeInfo(session) }) ipcMain.handle(IPC_CHANNELS.SESSION_RESUME, async (_event, sessionId: string) => { const session = await conductor.resumeSession(sessionId) - return withDirectoryExists(session) + return withRuntimeInfo(session) }) ipcMain.handle(IPC_CHANNELS.SESSION_START_AGENT, async (_event, sessionId: string) => { const session = await conductor.startSessionAgent(sessionId) - return withDirectoryExists(session) + return withRuntimeInfo(session) }) ipcMain.handle(IPC_CHANNELS.SESSION_DELETE, async (_event, sessionId: string) => { @@ -178,7 +183,7 @@ export function registerIPCHandlers(conductor: Conductor): void { } const updated = await conductor.updateSessionMeta(sessionId, updates) - return withDirectoryExists(updated) + return withRuntimeInfo(updated) } ) @@ -187,7 +192,7 @@ export function registerIPCHandlers(conductor: Conductor): void { async (_event, sessionId: string, newAgentId: string) => { try { const session = await conductor.switchSessionAgent(sessionId, newAgentId) - return withDirectoryExists(session) + return withRuntimeInfo(session) } catch (err) { throw new Error(extractErrorMessage(err)) } diff --git a/src/main/utils/git.ts b/src/main/utils/git.ts new file mode 100644 index 0000000000..f570166dd6 --- /dev/null +++ b/src/main/utils/git.ts @@ -0,0 +1,34 @@ +/** + * Git utilities for detecting repository information + */ +import { execFile } from 'child_process' +import { existsSync } from 'fs' +import { join } from 'path' + +/** + * Get the current git branch name for a directory + * Returns undefined if the directory is not a git repository or git is not available + */ +export function getGitBranch(directory: string): Promise { + return new Promise((resolve) => { + // Quick check: does .git exist? + if (!existsSync(join(directory, '.git'))) { + resolve(undefined) + return + } + + execFile( + 'git', + ['rev-parse', '--abbrev-ref', 'HEAD'], + { cwd: directory, timeout: 3000 }, + (error, stdout) => { + if (error) { + resolve(undefined) + return + } + const branch = stdout.trim() + resolve(branch || undefined) + } + ) + }) +} diff --git a/src/renderer/src/components/AppSidebar.tsx b/src/renderer/src/components/AppSidebar.tsx index 0ace7173da..12798ae7d9 100644 --- a/src/renderer/src/components/AppSidebar.tsx +++ b/src/renderer/src/components/AppSidebar.tsx @@ -103,8 +103,14 @@ function SessionItem({ ) : null} - {/* Line 2: Timestamp */} + {/* Line 2: Git branch (if available) + Timestamp */} + {session.gitBranch && ( + <> + {session.gitBranch} + ยท + + )} {formatDate(session.updatedAt)} diff --git a/src/shared/types/session.ts b/src/shared/types/session.ts index 0384a5a847..7f7dd7b2a6 100644 --- a/src/shared/types/session.ts +++ b/src/shared/types/session.ts @@ -28,6 +28,7 @@ export interface MulticaSession { // Runtime state (not persisted) directoryExists?: boolean // true = exists, false = deleted/moved, undefined = not checked + gitBranch?: string // Current git branch name (runtime only, refreshed on load) } /** diff --git a/tests/unit/main/utils/git.test.ts b/tests/unit/main/utils/git.test.ts new file mode 100644 index 0000000000..950229686b --- /dev/null +++ b/tests/unit/main/utils/git.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock child_process +vi.mock('child_process', () => ({ + execFile: vi.fn() +})) + +// Mock fs +vi.mock('fs', () => ({ + existsSync: vi.fn() +})) + +import { execFile } from 'child_process' +import { existsSync } from 'fs' +import { getGitBranch } from '../../../../src/main/utils/git' + +const mockExecFile = vi.mocked(execFile) +const mockExistsSync = vi.mocked(existsSync) + +describe('git utils', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getGitBranch', () => { + it('should return undefined when .git directory does not exist', async () => { + mockExistsSync.mockReturnValue(false) + + const result = await getGitBranch('/some/directory') + + expect(result).toBeUndefined() + expect(mockExecFile).not.toHaveBeenCalled() + }) + + it('should return branch name when git repo exists', async () => { + mockExistsSync.mockReturnValue(true) + mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + if (typeof callback === 'function') { + callback(null, 'main\n', '') + } + return {} as ReturnType + }) + + const result = await getGitBranch('/some/git-repo') + + expect(result).toBe('main') + expect(mockExecFile).toHaveBeenCalledWith( + 'git', + ['rev-parse', '--abbrev-ref', 'HEAD'], + { cwd: '/some/git-repo', timeout: 3000 }, + expect.any(Function) + ) + }) + + it('should return undefined when git command fails', async () => { + mockExistsSync.mockReturnValue(true) + mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + if (typeof callback === 'function') { + callback(new Error('git error'), '', '') + } + return {} as ReturnType + }) + + const result = await getGitBranch('/some/directory') + + expect(result).toBeUndefined() + }) + + it('should return undefined when git returns empty string', async () => { + mockExistsSync.mockReturnValue(true) + mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + if (typeof callback === 'function') { + callback(null, '', '') + } + return {} as ReturnType + }) + + const result = await getGitBranch('/some/directory') + + expect(result).toBeUndefined() + }) + + it('should trim whitespace from branch name', async () => { + mockExistsSync.mockReturnValue(true) + mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + if (typeof callback === 'function') { + callback(null, ' feature/my-branch \n', '') + } + return {} as ReturnType + }) + + const result = await getGitBranch('/some/git-repo') + + expect(result).toBe('feature/my-branch') + }) + + it('should handle detached HEAD state', async () => { + mockExistsSync.mockReturnValue(true) + mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => { + if (typeof callback === 'function') { + callback(null, 'HEAD\n', '') + } + return {} as ReturnType + }) + + const result = await getGitBranch('/some/git-repo') + + expect(result).toBe('HEAD') + }) + }) +})