mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 20:15:37 +02:00
Merge pull request #70 from multica-ai/feat/show-git-branch-in-session
feat: display git branch info in session sidebar
This commit is contained in:
@@ -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<T extends MulticaSession>(
|
||||
async function withRuntimeInfo<T extends MulticaSession>(
|
||||
session: T
|
||||
): T & { directoryExists: boolean } {
|
||||
): Promise<T & { directoryExists: boolean; gitBranch?: string }> {
|
||||
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))
|
||||
}
|
||||
|
||||
34
src/main/utils/git.ts
Normal file
34
src/main/utils/git.ts
Normal file
@@ -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<string | undefined> {
|
||||
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)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -103,8 +103,14 @@ function SessionItem({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Line 2: Timestamp */}
|
||||
{/* Line 2: Git branch (if available) + Timestamp */}
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{session.gitBranch && (
|
||||
<>
|
||||
<span className="font-medium">{session.gitBranch}</span>
|
||||
<span className="mx-1">·</span>
|
||||
</>
|
||||
)}
|
||||
{formatDate(session.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
111
tests/unit/main/utils/git.test.ts
Normal file
111
tests/unit/main/utils/git.test.ts
Normal file
@@ -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<typeof execFile>
|
||||
})
|
||||
|
||||
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<typeof execFile>
|
||||
})
|
||||
|
||||
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<typeof execFile>
|
||||
})
|
||||
|
||||
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<typeof execFile>
|
||||
})
|
||||
|
||||
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<typeof execFile>
|
||||
})
|
||||
|
||||
const result = await getGitBranch('/some/git-repo')
|
||||
|
||||
expect(result).toBe('HEAD')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user