mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
fix: bound git-info git probes (#908)
Tested: bun test src/server/__tests__/sessions.test.ts -t "git-info" Tested: bun run check:server Confidence: high Scope-risk: narrow
This commit is contained in:
parent
2c5c904c0a
commit
c324aee51d
@ -2395,6 +2395,56 @@ describe('Sessions API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/git-info should not hang when git blocks in a UTF-8 workDir', async () => {
|
||||
if (process.platform === 'win32') return
|
||||
|
||||
const parentDir = path.join(tmpDir, '数据包看板')
|
||||
const workDir = path.join(parentDir, 'datavizprocessingplatform')
|
||||
await fs.mkdir(workDir, { recursive: true })
|
||||
git(workDir, 'init', '--initial-branch', 'main')
|
||||
git(workDir, 'config', 'user.email', 'sessions-api@example.com')
|
||||
git(workDir, 'config', 'user.name', 'Sessions API')
|
||||
await fs.writeFile(path.join(workDir, 'README.md'), 'main\n')
|
||||
git(workDir, 'add', 'README.md')
|
||||
git(workDir, 'commit', '-m', 'initial')
|
||||
|
||||
const fsmonitorPath = path.join(tmpDir, 'slow-fsmonitor.sh')
|
||||
await fs.writeFile(fsmonitorPath, '#!/bin/sh\nsleep 2\nexit 0\n', 'utf-8')
|
||||
await fs.chmod(fsmonitorPath, 0o755)
|
||||
git(workDir, 'config', 'core.fsmonitor', fsmonitorPath)
|
||||
|
||||
const { sessionId } = await sessionService.createSession(workDir)
|
||||
const oldTimeout = process.env.CC_HAHA_GIT_INFO_TIMEOUT_MS
|
||||
process.env.CC_HAHA_GIT_INFO_TIMEOUT_MS = '80'
|
||||
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/git-info`, {
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
})
|
||||
expect(Date.now() - startedAt).toBeLessThan(1_000)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as {
|
||||
branch: string | null
|
||||
repoName: string | null
|
||||
workDir: string
|
||||
changedFiles: number
|
||||
}
|
||||
expect(body.workDir).toBe(await fs.realpath(workDir))
|
||||
expect(body.workDir).toContain('数据包看板')
|
||||
expect(body.branch).toBe('main')
|
||||
expect(body.repoName).toBe('datavizprocessingplatform')
|
||||
expect(body.changedFiles).toBe(0)
|
||||
} finally {
|
||||
if (oldTimeout === undefined) {
|
||||
delete process.env.CC_HAHA_GIT_INFO_TIMEOUT_MS
|
||||
} else {
|
||||
process.env.CC_HAHA_GIT_INFO_TIMEOUT_MS = oldTimeout
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('DELETE /api/sessions/:id should delete the session', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()])
|
||||
|
||||
@ -44,6 +44,8 @@ import { registerChangedFileAccessRoot, registerFilesystemAccessRoot } from '../
|
||||
import { findGitRoot } from '../../utils/git.js'
|
||||
import { traceCaptureService, trimTraceCallPreviews } from '../services/traceCaptureService.js'
|
||||
|
||||
const DEFAULT_GIT_INFO_COMMAND_TIMEOUT_MS = 3_000
|
||||
|
||||
const workspaceService = new WorkspaceService(
|
||||
async (sessionId) => (
|
||||
conversationService.getSessionWorkDir(sessionId) ||
|
||||
@ -725,6 +727,66 @@ function sameResolvedPath(left: string | null | undefined, right: string | null
|
||||
return path.resolve(left) === path.resolve(right)
|
||||
}
|
||||
|
||||
function getGitInfoCommandTimeoutMs(): number {
|
||||
const raw = process.env.CC_HAHA_GIT_INFO_TIMEOUT_MS
|
||||
if (!raw) return DEFAULT_GIT_INFO_COMMAND_TIMEOUT_MS
|
||||
const parsed = Number(raw)
|
||||
return Number.isFinite(parsed) && parsed > 0
|
||||
? parsed
|
||||
: DEFAULT_GIT_INFO_COMMAND_TIMEOUT_MS
|
||||
}
|
||||
|
||||
async function runGitInfoCommand(workDir: string, args: string[]): Promise<string | null> {
|
||||
let proc: Bun.Subprocess<'ignore', 'pipe', 'ignore'> | null = null
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
try {
|
||||
proc = Bun.spawn(['git', ...args], {
|
||||
cwd: workDir,
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
|
||||
const output = new Response(proc.stdout).text()
|
||||
.then(async (text) => (await proc!.exited) === 0 ? text.trim() : null)
|
||||
.catch(() => null)
|
||||
|
||||
const timedOut = new Promise<null>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
try {
|
||||
proc?.kill()
|
||||
} catch {
|
||||
// Process may already have exited.
|
||||
}
|
||||
resolve(null)
|
||||
}, getGitInfoCommandTimeoutMs())
|
||||
})
|
||||
|
||||
return await Promise.race([output, timedOut])
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function repoNameFromRemote(remote: string | null): string {
|
||||
if (!remote) return ''
|
||||
const match = remote.match(/\/([^/]+?)(?:\.git)?$/) || remote.match(/:([^/]+\/[^/]+?)(?:\.git)?$/)
|
||||
return match ? match[1]! : ''
|
||||
}
|
||||
|
||||
function ownerRepoNameFromRemote(remote: string | null): string | null {
|
||||
if (!remote) return null
|
||||
const match = remote.match(/:([^/]+\/[^/]+?)(?:\.git)?$/) || remote.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/)
|
||||
return match ? match[1]! : null
|
||||
}
|
||||
|
||||
function repoNameFromWorkDir(workDir: string): string {
|
||||
return path.basename(workDir) || workDir.split(/[\\/]/).filter(Boolean).at(-1) || ''
|
||||
}
|
||||
|
||||
async function getGitInfo(sessionId: string): Promise<Response> {
|
||||
const workDir = conversationService.getSessionWorkDir(sessionId) || await sessionService.getSessionWorkDir(sessionId)
|
||||
if (!workDir) {
|
||||
@ -754,7 +816,7 @@ async function getGitInfo(sessionId: string): Promise<Response> {
|
||||
// spawns on non-git directories (each can take seconds as git searches upward).
|
||||
const gitRoot = findGitRoot(workDir)
|
||||
if (!gitRoot) {
|
||||
const dirName = workDir.split('/').pop() || workDir.split(path.sep).pop() || ''
|
||||
const dirName = repoNameFromWorkDir(workDir)
|
||||
return Response.json({
|
||||
branch: sessionBranch,
|
||||
repoName: dirName,
|
||||
@ -766,13 +828,7 @@ async function getGitInfo(sessionId: string): Promise<Response> {
|
||||
|
||||
try {
|
||||
// Get branch name
|
||||
const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
cwd: workDir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const branchText = await new Response(branchProc.stdout).text()
|
||||
const gitBranch = branchText.trim() || null
|
||||
const gitBranch = await runGitInfoCommand(workDir, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
||||
const materializedWorktree = !!worktree && (
|
||||
sameResolvedPath(workDir, worktree.path) ||
|
||||
sameResolvedPath(workDir, worktree.plannedPath)
|
||||
@ -784,32 +840,12 @@ async function getGitInfo(sessionId: string): Promise<Response> {
|
||||
)
|
||||
|
||||
// Get repo name from remote or directory
|
||||
let repoName = ''
|
||||
try {
|
||||
const remoteProc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], {
|
||||
cwd: workDir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const remoteText = await new Response(remoteProc.stdout).text()
|
||||
const remote = remoteText.trim()
|
||||
// Extract repo name from URL: git@github.com:user/repo.git or https://...repo.git
|
||||
const match = remote.match(/\/([^/]+?)(?:\.git)?$/) || remote.match(/:([^/]+\/[^/]+?)(?:\.git)?$/)
|
||||
repoName = match ? match[1]! : ''
|
||||
} catch {
|
||||
// No remote, use directory name
|
||||
const parts = workDir.split('/')
|
||||
repoName = parts[parts.length - 1] || ''
|
||||
}
|
||||
const remote = await runGitInfoCommand(workDir, ['remote', 'get-url', 'origin'])
|
||||
const repoName = repoNameFromRemote(remote) || repoNameFromWorkDir(workDir)
|
||||
|
||||
// Get short status
|
||||
const statusProc = Bun.spawn(['git', 'status', '--porcelain'], {
|
||||
cwd: workDir,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const statusText = await new Response(statusProc.stdout).text()
|
||||
const changedFiles = statusText.trim().split('\n').filter(Boolean).length
|
||||
const statusText = await runGitInfoCommand(workDir, ['-c', 'core.fsmonitor=false', 'status', '--porcelain'])
|
||||
const changedFiles = statusText?.split('\n').filter(Boolean).length ?? 0
|
||||
|
||||
return Response.json({
|
||||
branch,
|
||||
@ -1079,25 +1115,11 @@ async function getRecentProjects(url: URL): Promise<Response> {
|
||||
// Run branch + remote in parallel
|
||||
try {
|
||||
const [branchResult, remoteResult] = await Promise.all([
|
||||
(async () => {
|
||||
const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
cwd: realPath, stdout: 'pipe', stderr: 'pipe',
|
||||
})
|
||||
return (await new Response(branchProc.stdout).text()).trim() || null
|
||||
})(),
|
||||
(async () => {
|
||||
try {
|
||||
const remoteProc = Bun.spawn(['git', 'remote', 'get-url', 'origin'], {
|
||||
cwd: realPath, stdout: 'pipe', stderr: 'pipe',
|
||||
})
|
||||
const remote = (await new Response(remoteProc.stdout).text()).trim()
|
||||
const match = remote.match(/:([^/]+\/[^/]+?)(?:\.git)?$/) || remote.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/)
|
||||
return match ? match[1]! : null
|
||||
} catch { return null }
|
||||
})(),
|
||||
runGitInfoCommand(realPath, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
||||
runGitInfoCommand(realPath, ['remote', 'get-url', 'origin']),
|
||||
])
|
||||
branch = isDesktopWorktreeBranchName(branchResult) ? null : branchResult
|
||||
repoName = remoteResult
|
||||
repoName = ownerRepoNameFromRemote(remoteResult)
|
||||
} catch { /* git command failed */ }
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user