Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 62 additions & 4 deletions apps/electron/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ import type {
} from '@proma/shared'
import type { UserProfile, AppSettings } from '../types'
import { getRuntimeStatus, getGitRepoStatus, reinitializeRuntime } from './lib/runtime-init'
import { getUnstagedChanges, getFileDiff, getUntrackedContent, revertFile, getDiffContents, listWorktrees, getWorktreeChanges, getMainRepoRoot } from './lib/git-diff-service'
import { getUnstagedChanges, invalidateGitDiffCache, getFileDiff, getUntrackedContent, revertFile, getDiffContents, listWorktrees, getWorktreeChanges, getMainRepoRoot, listRepos, getRepoChanges } from './lib/git-diff-service'
import { registerPromaFilePath } from './lib/local-file-protocol'
import { registerUpdaterIpc } from './lib/updater/updater-ipc'
import {
Expand Down Expand Up @@ -967,6 +967,15 @@ export function registerIpcHandlers(): void {
}
)

// 使变更扫描缓存失效:agent 写文件 / git 变更完成后由渲染层调用,
// 保证下次 getUnstagedChanges 重新扫描并返回最新结果。可传 writtenPath 定向失效。
ipcMain.handle(
IPC_CHANNELS.INVALIDATE_GIT_DIFF_CACHE,
async (_, writtenPath?: string): Promise<void> => {
invalidateGitDiffCache(writtenPath)
}
)

// 获取单个文件的 diff
ipcMain.handle(
IPC_CHANNELS.GET_FILE_DIFF,
Expand Down Expand Up @@ -1009,6 +1018,9 @@ export function registerIpcHandlers(): void {
const access = normalizeFileAccessOptions({ sessionId })
if (!(await ensurePathAllowedWithWorktree(dirPath, access)) || (gitRoot && !(await ensurePathAllowedWithWorktree(gitRoot, access)))) return
await revertFile(dirPath, filePath, gitRoot)
// 还原会改变工作树,定向失效该文件所在仓库的变更扫描缓存。
// filePath 是 repo 根相对路径,必须用 gitRoot 拼绝对路径才能命中定向匹配。
invalidateGitDiffCache(gitRoot ? join(gitRoot, filePath) : filePath)
}
)

Expand All @@ -1030,9 +1042,9 @@ export function registerIpcHandlers(): void {
// 列出 Git Worktree(只读取 worktree 元信息,不涉及文件内容,跳过路径安全检查)
ipcMain.handle(
IPC_CHANNELS.LIST_WORKTREES,
async (_, repoPath: string, _sessionId: string) => {
async (_, repoPath: string, _sessionId: string, force?: boolean) => {
if (!repoPath || typeof repoPath !== 'string') return []
return await listWorktrees(repoPath)
return await listWorktrees(repoPath, force === true)
}
)

Expand All @@ -1047,7 +1059,53 @@ export function registerIpcHandlers(): void {
if (!(await ensurePathAllowedWithWorktree(worktreePath, access))) {
return { isGitRepo: false, files: [], untrackedFiles: [], gitRootNames: [] }
}
return getWorktreeChanges(worktreePath, baseBranch)
// baseBranch 为空时服务端自动探测(origin/main → origin/master → …)
return getWorktreeChanges(worktreePath, baseBranch || undefined)
}
)

// 列出扫描到的所有 Git 仓库(仓库选择器用)
ipcMain.handle(
IPC_CHANNELS.LIST_REPOS,
async (_, dirPath: string, sessionId?: string, force?: boolean) => {
if (!dirPath || typeof dirPath !== 'string') return []
const access = normalizeFileAccessOptions({ sessionId })
if (!(await ensurePathAllowed(dirPath, access))) return []
return listRepos(dirPath, { force })
}
)

// 获取仓库所有 worktree 的全量变更(仓库聚合视图用)
ipcMain.handle(
IPC_CHANNELS.GET_REPO_CHANGES,
async (_, repoPath: string, baseBranch: string, sessionId: string) => {
if (!repoPath || typeof repoPath !== 'string') {
return { isGitRepo: false, repoPath: '', baseBranch: '', worktrees: [] }
}
const access = normalizeFileAccessOptions({ sessionId })
if (!(await ensurePathAllowedWithWorktree(repoPath, access))) {
return { isGitRepo: false, repoPath, baseBranch: '', worktrees: [] }
}
// 防御纵深:worktree 元数据可能指向未授权目录,逐 worktree 校验后再收集;
// baseBranch 为空时服务端自动探测(origin/main → origin/master → …)
return getRepoChanges(repoPath, baseBranch || undefined, {
isPathAllowed: (p) => ensurePathAllowedWithWorktree(p, access),
sessionId,
})
}
)

// 打开系统目录选择对话框(手动添加仓库用;Electron 不支持 window.prompt)
ipcMain.handle(
IPC_CHANNELS.SELECT_DIRECTORY,
async (event): Promise<string | null> => {
const win = BrowserWindow.fromWebContents(event.sender)
const options: Electron.OpenDialogOptions = {
title: '选择 Git 仓库根目录',
properties: ['openDirectory', 'promptToCreate'],
}
const result = win ? await dialog.showOpenDialog(win, options) : await dialog.showOpenDialog(options)
return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0]!
}
)

Expand Down
93 changes: 93 additions & 0 deletions apps/electron/src/main/lib/git-diff-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* git-diff-service 集成测试 — 用临时 git 仓库验证仓库发现 / worktree 枚举 / 聚合变更。
*
* 每个用例使用独立临时仓库,避免共享缓存与状态污染。
* 依赖真实 git 可执行文件(与运行时一致),测试前请确保 git 可用。
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { execSync } from 'child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { findAllGitRoots, getRepoChanges, getWorktreeChanges, invalidateGitDiffCache, listRepos } from './git-diff-service'

let cleanupDirs: string[] = []

function makeRepo(): string {
const root = mkdtempSync(join(tmpdir(), 'gdser-'))
cleanupDirs.push(root)
const run = (args: string[]) => execSync(`git ${args.join(' ')}`, { cwd: root, stdio: 'pipe' })
run(['init', '-b', 'main'])
run(['config', 'user.email', 'test@test.com'])
run(['config', 'user.name', 'test'])
writeFileSync(join(root, 'a.txt'), 'hello\n')
run(['add', '.'])
run(['commit', '-m', 'init'])
return root
}

afterEach(() => {
invalidateGitDiffCache()
for (const dir of cleanupDirs) {
try {
rmSync(dir, { recursive: true, force: true })
} catch {
// ignore
}
}
cleanupDirs = []
})

describe('git-diff-service 仓库发现与聚合', () => {
it('findAllGitRoots 能发现仓库根', async () => {
const root = makeRepo()
const roots = await findAllGitRoots(root)
expect(roots).toContain(root.replace(/\\/g, '/'))
})

it('listRepos 列出仓库(含主 worktree)', async () => {
const root = makeRepo()
writeFileSync(join(root, 'a.txt'), 'hello world\n') // 未提交改动
const repos = await listRepos(root)
expect(repos.length).toBe(1)
expect(repos[0]!.name).toBe(root.split(/[\\/]/).pop() ?? '')
expect(repos[0]!.branch).toBe('main')
expect(repos[0]!.worktreeCount).toBe(1)
expect(repos[0]!.worktrees[0]!.isMain).toBe(true)
})

it('listRepos 枚举额外 worktree,且新 worktree 立即可见', async () => {
const root = makeRepo()
execSync('git worktree add -b dev wt-dev', { cwd: root, stdio: 'pipe' })
// 清缓存模拟跨扫描周期(worktree 列表 / 仓库列表均有 TTL)
invalidateGitDiffCache()
const repos = await listRepos(root)
expect(repos.length).toBe(1)
expect(repos[0]!.worktreeCount).toBe(2)
const branches = repos[0]!.worktrees.map((w) => w.branch)
expect(branches).toContain('main')
expect(branches).toContain('dev')
})

it('getRepoChanges 聚合未提交改动并探测基准分支', async () => {
const root = makeRepo()
writeFileSync(join(root, 'a.txt'), 'hello world\n') // 未提交改动
const repos = await listRepos(root)
const result = await getRepoChanges(repos[0]!.repoPath)
expect(result.isGitRepo).toBe(true)
expect(result.worktrees.length).toBe(1)
const files = result.worktrees[0]!.changes.files
expect(files.some((f) => f.filePath === 'a.txt' && f.status === 'modified')).toBe(true)
// 无远端时回退到本地 main 作为基准
expect(result.baseBranch).toBe('main')
})

it('getWorktreeChanges 返回自动探测的基准分支', async () => {
const root = makeRepo()
writeFileSync(join(root, 'a.txt'), 'hello world\n')
const result = await getWorktreeChanges(root)
expect(result.isGitRepo).toBe(true)
expect(result.baseBranch).toBe('main')
expect(result.files.some((f) => f.filePath === 'a.txt')).toBe(true)
})
})
Loading