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:
LinYushen
2026-01-20 16:08:10 +08:00
committed by GitHub
5 changed files with 169 additions and 12 deletions

View File

@@ -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
View 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)
}
)
})
}

View File

@@ -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>

View File

@@ -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)
}
/**

View 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')
})
})
})