desktop/electron/gitStatus.cjs
Rama main · réplica de lectura del repositorio original.
const { execFile } = require('child_process')
const { spawn } = require('child_process')
const readline = require('readline')
const fs = require('fs')
const path = require('path')
const GIT_TIMEOUT_MS = 60_000
/** 测试可用 MAO_TEST_GIT_TIMEOUT_MS 覆盖以缩短挂起类用例的等待时间(小于 1s 视为无效)。 */
function gitTimeoutMs() {
const override = Number(process.env.MAO_TEST_GIT_TIMEOUT_MS)
return Number.isFinite(override) && override >= 1000 ? override : GIT_TIMEOUT_MS
}
const MAX_STDOUT_BYTES = 2 * 1024 * 1024
const MAX_DIFF_LINES = 5000
const MAX_DIFF_BYTES = 512 * 1024
const EXCLUDED_REPO_DIRS = new Set([
'.nvm', '.pyenv', '.rbenv', '.nodenv', '.jenv', '.tfenv', '.sdkman',
'.oh-my-zsh', '.zprezto', '.zim', '.zinit', '.antigen', '.fzf',
])
function runGit(cwd, args) {
return new Promise((resolve) => {
let settled = false
const finish = (result) => {
if (settled) return
settled = true
resolve(result)
}
const child = execFile(
'git',
['-c', 'core.quotepath=false', ...args],
{
cwd,
encoding: 'utf8',
maxBuffer: MAX_STDOUT_BYTES,
timeout: gitTimeoutMs(),
killSignal: 'SIGKILL',
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
},
(err, stdout, stderr) => {
finish({
exitCode: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
stdout: stdout || '',
stderr: stderr || '',
timedOut: Boolean(err && (err.killed || err.code === 'ETIMEDOUT')),
// execFile maxBuffer 溢出(2 MiB)以 ERR_CHILD_PROCESS_STDIO_MAXBUFFER 终结,
// 但回调仍带预算内的已收集 stdout:读大文件内容的调用方按「成功 + 截断」处理
// (见 getGitBranchCompareFileDiff),避免静默退化成空内容。
overflowed: Boolean(err && err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'),
})
},
)
child.on('error', (error) => {
finish({ exitCode: 127, stdout: '', stderr: error.message || '', timedOut: false, overflowed: false })
})
})
}
async function runGitOk(cwd, args) {
const result = await runGit(cwd, args)
if (result.exitCode !== 0) return null
return result.stdout
}
function truncateText(content) {
if (content == null) return { content: '', truncated: false }
let truncated = false
const lines = content.split('\n')
if (lines.length > MAX_DIFF_LINES) {
content = lines.slice(0, MAX_DIFF_LINES).join('\n')
truncated = true
}
const buf = Buffer.from(content, 'utf8')
if (buf.length > MAX_DIFF_BYTES) {
let end = MAX_DIFF_BYTES
while (end > 0 && (buf[end - 1] & 0xc0) === 0x80) end--
content = buf.subarray(0, end).toString('utf8')
truncated = true
}
return { content, truncated }
}
function isBinaryBuffer(buf) {
for (let i = 0; i < buf.length; i++) {
if (buf[i] === 0) return true
}
return false
}
function countLines(content) {
if (!content) return 0
let lines = 1
for (let i = 0; i < content.length; i++) {
if (content[i] === '\n') lines++
}
if (content.endsWith('\n') && lines > 1) lines--
return Math.max(lines, 1)
}
function readTextLimited(filePath) {
let fd
try {
const size = fs.statSync(filePath).size
const limit = Math.min(size, MAX_DIFF_BYTES + 1)
fd = fs.openSync(filePath, 'r')
const buf = Buffer.alloc(limit)
const bytesRead = fs.readSync(fd, buf, 0, limit, 0)
const content = buf.subarray(0, bytesRead)
if (isBinaryBuffer(content)) return { content: '', truncated: false, binary: true }
const result = truncateText(content.toString('utf8'))
return { ...result, truncated: result.truncated || size > bytesRead, binary: false }
} catch {
return { content: '', truncated: false, binary: true }
} finally {
if (fd != null) fs.closeSync(fd)
}
}
function parseNameStatusLine(line) {
const parts = line.split('\t')
if (parts.length < 2) return null
const status = parts[0].trim()
const code = status.charAt(0)
if (code === 'A') return { path: parts[1].replace(/\\/g, '/'), changeType: 'CREATED' }
if (code === 'M') return { path: parts[1].replace(/\\/g, '/'), changeType: 'MODIFIED' }
if (code === 'D') return { path: parts[1].replace(/\\/g, '/'), changeType: 'DELETED' }
if (code === 'R' && parts.length >= 3) {
return {
path: parts[2].replace(/\\/g, '/'),
oldPath: parts[1].replace(/\\/g, '/'),
changeType: 'RENAMED',
}
}
if (code === 'C' && parts.length >= 3) {
return {
path: parts[2].replace(/\\/g, '/'),
oldPath: parts[1].replace(/\\/g, '/'),
changeType: 'COPIED',
}
}
return { path: parts[parts.length - 1].replace(/\\/g, '/'), changeType: 'MODIFIED' }
}
async function collectChangedFiles(repoRoot) {
/** @type {Map<string, any>} */
const files = new Map()
let nameStatus = await runGitOk(repoRoot, ['diff', '--name-status', 'HEAD'])
if (!nameStatus && !(await runGitOk(repoRoot, ['rev-parse', '--verify', 'HEAD']))) {
// 空仓库(无 commit,HEAD 不存在):diff --name-status HEAD 必然失败,
// 回退到 --cached 统计已 staged 的文件,与 getRepos(porcelain 计入 staged)口径一致
nameStatus = await runGitOk(repoRoot, ['diff', '--name-status', '--cached'])
}
if (nameStatus) {
for (const raw of nameStatus.split('\n')) {
const line = raw.trim()
if (!line) continue
const file = parseNameStatusLine(line)
if (file) files.set(file.path, { insertions: 0, deletions: 0, ...file })
}
}
let numstat = await runGitOk(repoRoot, ['diff', '--numstat', 'HEAD'])
if (!numstat && !(await runGitOk(repoRoot, ['rev-parse', '--verify', 'HEAD']))) {
numstat = await runGitOk(repoRoot, ['diff', '--numstat', '--cached'])
}
if (numstat) {
for (const raw of numstat.split('\n')) {
const line = raw.trim()
if (!line) continue
const parts = line.split('\t')
if (parts.length < 3) continue
// 与后端 collectChangedFiles 同口径:numstat 的改名是紧凑形态(src/{a.txt => b.txt}),
// 简单切片会还原出带花括号的错键、丢合并,必须用 numstatNewPath 还原
const filePath = numstatNewPath(parts[parts.length - 1])
const file = files.get(filePath)
if (!file) continue
if (parts[0] === '-' || parts[1] === '-') {
file.binary = true
file.insertions = 0
file.deletions = 0
} else {
file.insertions = parseInt(parts[0], 10) || 0
file.deletions = parseInt(parts[1], 10) || 0
}
}
}
const untracked = await runGitOk(repoRoot, ['ls-files', '--others', '--exclude-standard'])
if (untracked) {
for (const raw of untracked.split('\n')) {
const filePath = raw.trim().replace(/\\/g, '/')
if (!filePath || files.has(filePath)) continue
const abs = path.join(repoRoot, filePath)
const entry = {
path: filePath,
changeType: 'CREATED',
untracked: true,
insertions: 0,
deletions: 0,
}
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
const read = readTextLimited(abs)
if (read.binary) {
entry.binary = true
} else {
entry.insertions = countLines(read.content)
}
}
files.set(filePath, entry)
}
}
return files
}
async function showHeadContent(repoRoot, filePath) {
if (!filePath) return null
const result = await runGit(repoRoot, ['show', `HEAD:${filePath}`])
if (result.exitCode !== 0) return null
return result.stdout
}
async function inferChangeType(repoRoot, filePath, absolute) {
const inHead = (await showHeadContent(repoRoot, filePath)) != null
const inWorktree = fs.existsSync(absolute) && fs.statSync(absolute).isFile()
if (!inHead && inWorktree) return 'CREATED'
if (inHead && !inWorktree) return 'DELETED'
return 'MODIFIED'
}
/**
* Resolve an optional repoPath (a first-level subdirectory name relative to the workspace).
* Returns the workspace itself when repoPath is absent. Throws on invalid paths.
* @param {string} workspace
* @param {string} [repoPath]
* @returns {string}
*/
function resolveRepoDir(workspace, repoPath) {
const ws = path.resolve(workspace)
if (!repoPath || typeof repoPath !== 'string' || !repoPath.trim()) {
return ws
}
const normalized = repoPath.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '')
if (!normalized || normalized === '.' || normalized.includes('/') || normalized.split('/').includes('..')) {
throw new Error('路径访问被拒绝')
}
const repoDir = path.resolve(ws, normalized)
// 用 path.relative 判断越界:兼容根目录工作区(如 '/')与 Windows 大小写差异
const rel = path.relative(ws, repoDir)
if (path.isAbsolute(rel) || rel === '..' || rel.startsWith('..' + path.sep)) {
throw new Error('路径访问被拒绝')
}
if (!fs.existsSync(repoDir) || !fs.statSync(repoDir).isDirectory()) {
throw new Error('路径访问被拒绝')
}
return repoDir
}
/**
* Discover first-level git repos under a non-git workspace.
* Mirrors the backend shape: { isRootGit, repos: [{ name, path, branch, changedFileCount }] }.
* 性能:每仓库仅 1 条 `git status --porcelain=v2 --branch` 命令(此前 4 条),
* 并发上限 8(此前无上限),大量仓库时进程数与峰值资源显著下降。
* @param {string} workspace
*/
async function listGitRepos(workspace) {
if (!workspace) {
return { isRootGit: false, repos: [] }
}
const ws = path.resolve(workspace)
const repoRootStr = await runGitOk(ws, ['rev-parse', '--show-toplevel'])
if (repoRootStr) {
return { isRootGit: true, repos: [] }
}
let entries = []
try {
entries = fs.readdirSync(ws, { withFileTypes: true })
} catch {
entries = []
}
const repoDirs = entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.filter((name) => !EXCLUDED_REPO_DIRS.has(name))
.filter((name) => {
try {
return fs.existsSync(path.join(ws, name, '.git'))
} catch {
return false
}
})
.sort((a, b) => a.localeCompare(b))
const summaries = (await mapLimit(repoDirs, 8, (name) => summarizeRepoDir(name, ws))).filter(Boolean)
return {
isRootGit: false,
repos: summaries,
}
}
/**
* 对单个仓库目录执行轻量统计:分支 + 变更文件数。
* 单条 `git status --porcelain=v2 --branch -M --untracked-files=all`:
* - 分支取自 `# branch.head`(detached 时 porcelain 输出 (detached),映射为 "HEAD" 与存量 rev-parse 语义一致);
* - 变更文件数 = 非 # 注释行数(tracked 行以 1/2 开头、untracked 行以 ? 开头);
* - spawn + readline 逐行统计(内存 O(1)),不依赖完整输出,避免大量变更/untracked 文件时
* 输出超限被 Node 杀死(execFile maxBuffer 上限的回归点);
* - 失败/超时返回 unavailable 占位条目,仓库不静默消失。
* @param {string} name
* @param {string} ws
* @returns {Promise<object>}
*/
function summarizeRepoDir(name, ws) {
const dir = path.join(ws, name)
return new Promise((resolve) => {
const child = spawn('git', ['-c', 'core.quotepath=false', 'status', '--porcelain=v2', '--branch', '-M', '--untracked-files=all'], {
cwd: dir,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
})
let branch
let changedFileCount = 0
const untrackedPaths = []
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
child.kill('SIGKILL')
}, GIT_TIMEOUT_MS)
const rl = readline.createInterface({ input: child.stdout })
rl.on('line', (line) => {
if (!line) return
if (line.startsWith('#')) {
if (line.startsWith('# branch.head ')) {
const b = line.slice('# branch.head '.length).trim()
branch = b === '(detached)' ? 'HEAD' : b
}
return
}
changedFileCount++
if (line.startsWith('? ')) untrackedPaths.push(line.slice(2))
})
child.stderr.resume() // 不合并 stderr,避免 git 警告混入计数;仅丢弃
child.on('error', () => {
clearTimeout(timer)
resolve({ name, path: name, changedFileCount: 0, unavailable: true })
})
child.on('close', async (code) => {
clearTimeout(timer)
if (timedOut || code !== 0) {
resolve({ name, path: name, changedFileCount: 0, unavailable: true })
return
}
let insertions = 0
let deletions = 0
if (changedFileCount > 0) {
let numstat = await runGitOk(dir, ['diff', '--numstat', 'HEAD'])
if (!numstat && !(await runGitOk(dir, ['rev-parse', '--verify', 'HEAD']))) {
numstat = await runGitOk(dir, ['diff', '--numstat', '--cached'])
}
if (numstat) {
for (const line of numstat.split('\n')) {
const parts = line.split('\t')
if (parts.length < 3 || parts[0] === '-' || parts[1] === '-') continue
insertions += parseInt(parts[0], 10) || 0
deletions += parseInt(parts[1], 10) || 0
}
}
for (const relativePath of untrackedPaths) {
const file = path.resolve(dir, relativePath)
if ((!file.startsWith(dir + path.sep) && file !== dir) || !fs.existsSync(file) || !fs.statSync(file).isFile()) continue
const read = readTextLimited(file)
if (!read.binary) insertions += countLines(read.content)
}
}
resolve({ name, path: name, branch, changedFileCount, insertions, deletions })
})
})
}
/**
* 有界并发执行异步映射:同时最多 limit 个任务在跑,结果保持输入顺序。
* @template T, R
* @param {T[]} items
* @param {number} limit
* @param {(item: T, idx: number, list: T[]) => Promise<R>} fn
* @returns {Promise<R[]>}
*/
async function mapLimit(items, limit, fn) {
const results = new Array(items.length)
let next = 0
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const idx = next++
results[idx] = await fn(items[idx], idx, items)
}
})
await Promise.all(workers)
return results
}
/**
* @param {string} workspace
* @param {string} [repoPath]
*/
async function getGitStatus(workspace, repoPath) {
if (!workspace) {
return { isGit: false, remoteStatusAvailable: false }
}
let cwd
try {
cwd = resolveRepoDir(workspace, repoPath)
} catch (e) {
return { isGit: false, remoteStatusAvailable: false, error: e.message || '路径无效' }
}
const repoRootStr = await runGitOk(cwd, ['rev-parse', '--show-toplevel'])
if (!repoRootStr) {
return { isGit: false, remoteStatusAvailable: false }
}
const repoRoot = path.resolve(repoRootStr.trim())
let branchOut = await runGitOk(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])
if (!branchOut) {
// 空仓库(无 commit)时 rev-parse 失败,用 symbolic-ref 取 unborn 分支名,与 getRepos 口径一致
const symbolic = await runGitOk(repoRoot, ['symbolic-ref', '--short', 'HEAD'])
branchOut = symbolic
}
const filesMap = await collectChangedFiles(repoRoot)
const files = Array.from(filesMap.values())
const [remotesOut, upstreamOut, symbolicHead, headOut] = await Promise.all([
runGitOk(repoRoot, ['remote']),
runGitOk(repoRoot, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']),
runGitOk(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']),
runGitOk(repoRoot, ['rev-parse', '--verify', 'HEAD']),
])
const remotes = remotesOut ? remotesOut.split(/\r?\n/).map((item) => item.trim()).filter(Boolean) : []
const detachedHead = !symbolicHead
let insertions = 0
let deletions = 0
for (const f of files) {
insertions += f.insertions || 0
deletions += f.deletions || 0
}
return {
isGit: true,
repoRoot,
branch: branchOut ? branchOut.trim() : undefined,
insertions,
deletions,
changedFileCount: files.length,
files,
remotes,
hasRemote: remotes.length > 0,
hasHead: Boolean(headOut),
detachedHead,
upstream: upstreamOut ? upstreamOut.trim() : undefined,
remoteStatusAvailable: false,
}
}
function sanitizeRemoteError(text) {
return String(text || '')
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1***@')
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, '$1***@')
.trim()
.slice(0, 4000)
}
function selectFetchRemote(status) {
if (status.upstream) {
const upstreamRemote = status.remotes
.filter((remote) => status.upstream.startsWith(`${remote}/`))
.sort((a, b) => b.length - a.length)[0]
if (upstreamRemote) return upstreamRemote
}
if (status.remotes.includes('origin')) return 'origin'
if (status.remotes.length === 1) return status.remotes[0]
return undefined
}
/**
* Fetch every remote in parallel (one git process per remote) before calculating divergence
* from the configured upstream. Fetch failures preserve the already collected local status.
* @param {string} workspace
* @param {string} [repoPath]
*/
async function refreshGitStatus(workspace, repoPath) {
const status = await getGitStatus(workspace, repoPath)
if (!status.isGit) return status
if (status.remotes.length === 0) {
return { ...status, remoteStatusError: '未配置可同步的 Git 远端' }
}
// 并行 fetch 全部远端(每个远端一个独立子进程、各自 60s 超时):任意多个远端挂起时
// 总等待以单个远端的超时为上界(串行会叠成 N×60s);单个远端挂起/失败不影响其余远端,
// 失败/超时天然归属到具体远端,无需解析 git 的本地化错误输出。
const results = await Promise.all(status.remotes.map(async (remote) => ({
remote,
result: await runGit(status.repoRoot, ['fetch', '--prune', remote]),
})))
const failures = results.filter((entry) => entry.result.exitCode !== 0)
let partialError = null
if (failures.length > 0) {
if (failures.length === status.remotes.length) {
const first = failures[0]
return {
...status,
remoteStatusError: failures.length === 1
? (first.result.timedOut
? `Git fetch 超过 ${Math.round(gitTimeoutMs() / 1000)} 秒,已终止`
: sanitizeRemoteError(first.result.stderr || first.result.stdout) || '获取远端更新失败')
: `全部远端获取失败:${failures.map((f) => f.remote).join('、')}`,
}
}
partialError = `部分远端获取失败:${failures.map((f) => f.remote).join('、')}(其余远端已更新)`
}
const refreshedStatus = await getGitStatus(workspace, repoPath)
if (!refreshedStatus.isGit) return refreshedStatus
// 部分远端失败:其余远端已更新,附加提示但不覆盖读取状态失败的原因
const withPartial = (extra) => (partialError == null || extra.remoteStatusError != null
? extra
: { ...extra, remoteStatusError: partialError })
if (!refreshedStatus.hasHead) {
return withPartial({ ...refreshedStatus, remoteStatusAvailable: true, hasCommitsToPush: false })
}
// 参照远端:仅用于「无 upstream 时」与远端同名分支比较;多远端且无法确定时为 null
const referenceRemote = selectFetchRemote(refreshedStatus)
if (!refreshedStatus.upstream) {
if (!referenceRemote) {
return withPartial({ ...refreshedStatus, remoteStatusAvailable: true, hasCommitsToPush: false })
}
const remoteBranch = `refs/remotes/${referenceRemote}/${refreshedStatus.branch}`
const exists = await runGit(refreshedStatus.repoRoot, ['rev-parse', '--verify', '--quiet', remoteBranch])
if (exists.exitCode !== 0) {
return withPartial({ ...refreshedStatus, remoteStatusAvailable: true, hasCommitsToPush: true })
}
const comparison = await runGit(refreshedStatus.repoRoot, ['rev-list', '--left-right', '--count', `HEAD...${remoteBranch}`])
const [ahead] = comparison.stdout.trim().split(/\s+/).map((value) => parseInt(value, 10))
if (comparison.exitCode !== 0 || !Number.isFinite(ahead)) {
return withPartial({ ...refreshedStatus, remoteStatusError: '读取 Git 远端状态失败' })
}
return withPartial({ ...refreshedStatus, remoteStatusAvailable: true, hasCommitsToPush: ahead > 0 })
}
const divergence = await runGit(refreshedStatus.repoRoot, ['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'])
if (divergence.exitCode !== 0) {
return withPartial({
...refreshedStatus,
remoteStatusError: sanitizeRemoteError(divergence.stderr || divergence.stdout) || '读取 Git 远端状态失败',
})
}
const [aheadCount, behindCount] = divergence.stdout.trim().split(/\s+/).map((value) => parseInt(value, 10))
if (!Number.isFinite(aheadCount) || !Number.isFinite(behindCount)) {
return withPartial({ ...refreshedStatus, remoteStatusError: '读取 Git 远端状态失败' })
}
return withPartial({ ...refreshedStatus, remoteStatusAvailable: true, aheadCount, behindCount, hasCommitsToPush: aheadCount > 0 })
}
/**
* @param {string} workspace
* @param {string} [repoPath]
* @param {string} relativePath
*/
async function getGitFileDiff(workspace, repoPath, relativePath) {
if (!workspace) {
return { path: relativePath || '', changeType: 'MODIFIED', beforeContent: '', afterContent: '', unavailableReason: '工作区无效' }
}
if (!relativePath || relativePath.replace(/\\/g, '/').split('/').includes('..')) {
return { path: relativePath || '', changeType: 'MODIFIED', beforeContent: '', afterContent: '', unavailableReason: '路径无效' }
}
const normalized = relativePath.replace(/\\/g, '/').replace(/^\.\//, '')
let cwd
try {
cwd = resolveRepoDir(workspace, repoPath)
} catch (e) {
return { path: normalized, changeType: 'MODIFIED', beforeContent: '', afterContent: '', unavailableReason: e.message || '路径无效' }
}
const repoRootStr = await runGitOk(cwd, ['rev-parse', '--show-toplevel'])
if (!repoRootStr) {
return { path: normalized, changeType: 'MODIFIED', beforeContent: '', afterContent: '', unavailableReason: '当前工作区不是 Git 仓库' }
}
const repoRoot = path.resolve(repoRootStr.trim())
const absolute = path.resolve(repoRoot, normalized)
if (!absolute.startsWith(repoRoot + path.sep) && absolute !== repoRoot) {
return { path: normalized, changeType: 'MODIFIED', beforeContent: '', afterContent: '', unavailableReason: '路径访问被拒绝' }
}
const filesMap = await collectChangedFiles(repoRoot)
let meta = filesMap.get(normalized)
if (!meta) {
meta = {
path: normalized,
changeType: await inferChangeType(repoRoot, normalized, absolute),
}
}
let before = await showHeadContent(repoRoot, meta.oldPath || normalized)
let after = ''
let truncated = false
const afterMissing = !fs.existsSync(absolute) || !fs.statSync(absolute).isFile()
if (!afterMissing) {
const afterRead = readTextLimited(absolute)
if (afterRead.binary) {
return {
path: normalized,
changeType: meta.changeType,
beforeContent: '',
afterContent: '',
binary: true,
unavailableReason: '二进制文件,无法预览',
}
}
after = afterRead.content
if (afterRead.truncated) truncated = true
}
if (before != null && before.includes('\0')) {
return {
path: normalized,
changeType: meta.changeType,
beforeContent: '',
afterContent: '',
binary: true,
unavailableReason: '二进制文件,无法预览',
}
}
if (before == null) before = ''
const beforeTrunc = truncateText(before)
const afterTrunc = truncateText(after)
return {
path: normalized,
changeType: meta.changeType,
beforeContent: beforeTrunc.content,
afterContent: afterTrunc.content,
truncated: truncated || beforeTrunc.truncated || afterTrunc.truncated,
}
}
/**
* resolved 是否在 root 之内(含相等)。realpath 两侧都取,兼容 macOS /var → /private/var
* 这类符号链接(git 的 --show-toplevel 返回真实路径,而 workspace 可能是符号链接路径)。
*/
function isUnderDir(resolved, root) {
const rel = path.relative(root, resolved)
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
}
function realPathOrSelf(target) {
try {
return fs.realpathSync(target)
} catch {
return target
}
}
/**
* Resolve the repo root for read-only queries. Throws when the workspace/repoPath
* is invalid or the target is not a git repository (IPC handlers surface the message).
* 与写路径 resolveRepository 同样校验 workspace 包含关系:rev-parse --show-toplevel
* 可能跳出工作区(工作区本身位于某个 git 仓库内部时),不校验会让只读方法作用到
* 外层仓库、getGitCommitFileDiff 读到工作区之外的受控文件内容。
* @param {string} workspace
* @param {string} [repoPath]
* @returns {Promise<string>}
*/
async function resolveRepoRoot(workspace, repoPath) {
if (!workspace) throw new Error('工作区无效')
const cwd = resolveRepoDir(workspace, repoPath)
const repoRootStr = await runGitOk(cwd, ['rev-parse', '--show-toplevel'])
if (!repoRootStr) throw new Error('当前工作区不是 Git 仓库')
const repoRoot = path.resolve(repoRootStr.trim())
const ws = path.resolve(workspace)
if (!isUnderDir(realPathOrSelf(repoRoot), realPathOrSelf(ws))) {
throw new Error('路径访问被拒绝')
}
return repoRoot
}
/**
* numstat 路径字段还原成完整新路径(与后端 numstatNewPath 同规则):
* git 对检测到的改名输出紧凑形态 `old => new` 或 `prefix/{old => new}suffix`,
* name-status 的 R 行始终输出完整 old/new,必须还原才能键对齐。
*/
function numstatNewPath(raw) {
const value = String(raw || '').replace(/\\/g, '/').trim()
const arrow = value.lastIndexOf(' => ')
if (arrow < 0) return value
const braceStart = value.lastIndexOf('{', arrow)
const braceEnd = braceStart >= 0 ? value.indexOf('}', arrow) : -1
if (braceStart >= 0 && braceEnd > arrow) {
return value.slice(0, braceStart) + value.slice(arrow + 4, braceEnd) + value.slice(braceEnd + 1)
}
return value.slice(arrow + 4).trim()
}
function parseLogLine(line) {
const parts = line.split('\0')
if (parts.length < 6) return null
const [hash, shortHash, title, author, date, refs] = parts
if (!hash.trim()) return null
const entry = {
hash: hash.trim(),
shortHash: shortHash.trim(),
title,
author: author.trim(),
date: date.trim(),
}
if (refs && refs.trim()) entry.refs = refs
return entry
}
/**
* 解析 `git branch -vv` 一行(与后端 parseBranchVvLine 同规则):
* `* main abc1234 [origin/main: ahead 1, behind 2] subject`
* detached 行 name 以 '(' 开头,由调用方跳过;空仓库未出生分支无 hash。
*/
function parseBranchVvLine(line) {
if (!line.trim()) return null
const isCurrent = line.startsWith('*')
let rest = line.replace(/^\*?\s+/, '')
const nameMatch = /^(\S+)\s*(.*)$/.exec(rest)
if (!nameMatch) return null
const name = nameMatch[1]
rest = nameMatch[2]
const hashMatch = /^([0-9a-f]{7,64})\s*(.*)$/.exec(rest)
let lastCommitHash
if (hashMatch) {
lastCommitHash = hashMatch[1]
rest = hashMatch[2]
}
let upstream
let ahead
let behind
const bracketMatch = /^\[([^\]]*)\]\s*(.*)$/.exec(rest)
if (bracketMatch) {
const inside = bracketMatch[1]
rest = bracketMatch[2]
const colonIdx = inside.indexOf(':')
const upstreamPart = (colonIdx >= 0 ? inside.slice(0, colonIdx) : inside).trim()
// 'gone' 是方括号内的跟踪态(upstream 在远端已被 prune),upstream 名本身仍展示
// (与 git branch -vv 输出一致);ahead/behind 只在明确给出时解析,gone 时自然留空
if (upstreamPart && upstreamPart !== 'gone') upstream = upstreamPart
if (colonIdx >= 0) {
const aheadMatch = /ahead (\d+)/.exec(inside)
const behindMatch = /behind (\d+)/.exec(inside)
if (aheadMatch) ahead = parseInt(aheadMatch[1], 10)
if (behindMatch) behind = parseInt(behindMatch[1], 10)
}
}
const lastCommitTitle = rest.trim() || undefined
return { name, isCurrent, upstream, ahead, behind, lastCommitHash, lastCommitTitle }
}
function clampInt(value, fallback, min, max) {
const n = value == null ? fallback : Math.floor(value)
if (!Number.isFinite(n)) return fallback
return Math.min(max, Math.max(min, n))
}
/**
* 提交历史(分页,与后端 getLog 同命令同解析)。
* @param {string} workspace
* @param {string} [repoPath]
* @param {{limit?: number, offset?: number, path?: string, keyword?: string}} [options]
* @returns {Promise<object[]>}
*/
async function getGitLog(workspace, repoPath, options = {}) {
const repoRoot = await resolveRepoRoot(workspace, repoPath)
const limit = clampInt(options.limit, 50, 1, 200)
const offset = clampInt(options.offset, 0, 0, Number.MAX_SAFE_INTEGER)
const args = ['log', '--format=%H%x00%h%x00%s%x00%an%x00%aI%x00%D', '-n', String(limit), '--skip', String(offset)]
// 提交信息关键词:--fixed-strings 字面匹配(正则元字符不解释)、-i 大小写不敏感,
// trim 后截断 100 字符,空值不产生参数;keyword 组必须插在 `--` 之前。
const keyword = options.keyword ? String(options.keyword).trim().slice(0, 100) : ''
if (keyword) args.push(`--grep=${keyword}`, '--fixed-strings', '-i')
if (options.path && String(options.path).trim()) args.push('--', String(options.path).trim())
const out = await runGitOk(repoRoot, args)
if (!out || !out.trim()) return []
const entries = []
for (const line of out.split('\n')) {
if (!line) continue
const entry = parseLogLine(line)
if (entry) entries.push(entry)
}
return entries
}
/**
* 单次提交的文件变更清单。merge 提交必须带 --first-parent(combined diff 干净合并清单为空)。
* @param {string} workspace
* @param {string} [repoPath]
* @param {string} hash
* @returns {Promise<{hash: string, files: object[], body: string}>}
*/
async function getGitCommitFiles(workspace, repoPath, hash) {
const normalizedHash = String(hash || '').trim().toLowerCase()
if (!/^[0-9a-f]{7,64}$/.test(normalizedHash)) throw new Error('提交不存在')
const repoRoot = await resolveRepoRoot(workspace, repoPath)
const nameStatus = await runGitOk(repoRoot, ['show', '--first-parent', '--name-status', '--format=', normalizedHash])
const numstat = await runGitOk(repoRoot, ['show', '--first-parent', '--numstat', '--format=', normalizedHash])
if (nameStatus == null && numstat == null) throw new Error('提交不存在')
const files = new Map()
if (nameStatus && nameStatus.trim()) {
for (const raw of nameStatus.split('\n')) {
const line = raw.trim()
if (!line) continue
const file = parseNameStatusLine(line)
if (file) files.set(file.path, { insertions: 0, deletions: 0, ...file })
}
}
if (numstat && numstat.trim()) {
for (const raw of numstat.split('\n')) {
const line = raw.trim()
if (!line) continue
const parts = line.split('\t')
if (parts.length < 3) continue
const filePath = numstatNewPath(parts[parts.length - 1])
const file = files.get(filePath)
if (!file) continue
if (parts[0] === '-' || parts[1] === '-') {
file.binary = true
file.insertions = 0
file.deletions = 0
} else {
file.insertions = parseInt(parts[0], 10) || 0
file.deletions = parseInt(parts[1], 10) || 0
}
}
}
// 提交正文:%B 去除首行标题后的剩余部分(trim,空串表示无正文),与后端 getCommitFiles 同解析
const message = await runGitOk(repoRoot, ['show', '-s', '--format=%B', normalizedHash])
const body = message == null ? '' : message.split('\n').slice(1).join('\n').trim()
return { hash: normalizedHash, files: Array.from(files.values()), body }
}
/**
* 单提交单文件 diff:before = `git show <hash>^:<oldPath ?? path>`,after = `git show <hash>:<path>`(均取自 git 对象)。
* 改名提交必须传 oldPath(文件清单 R 行的旧路径)——新路径在父提交中不存在,不传会把 before
* 判空、changeType 退化成 CREATED,与同屏文件清单的「改名」徽章自相矛盾。
* 新增文件 before 为空(根提交同理),删除文件 after 为空(show 非零退出即空内容)。
* @param {string} workspace
* @param {string} [repoPath]
* @param {string} hash
* @param {string} relativePath
* @param {string} [oldPath]
* @returns {Promise<object>}
*/
async function getGitCommitFileDiff(workspace, repoPath, hash, relativePath, oldPath) {
const normalizedHash = String(hash || '').trim().toLowerCase()
if (!/^[0-9a-f]{7,64}$/.test(normalizedHash)) {
return { path: relativePath || '', changeType: 'MODIFIED', beforeContent: '', afterContent: '', unavailableReason: '提交不存在' }
}
if (!relativePath || relativePath.replace(/\\/g, '/').split('/').includes('..')) {
return { path: relativePath || '', changeType: 'MODIFIED', beforeContent: '', afterContent: '', unavailableReason: '路径无效' }
}
const normalized = relativePath.replace(/\\/g, '/').replace(/^\.\//, '')
const beforePath = oldPath == null || String(oldPath).trim().length === 0
? normalized
: String(oldPath).replace(/\\/g, '/').replace(/^\.\//, '')
if (beforePath.split('/').includes('..')) {
return { path: normalized, changeType: 'MODIFIED', beforeContent: '', afterContent: '', unavailableReason: '路径无效' }
}
let repoRoot
try {
repoRoot = await resolveRepoRoot(workspace, repoPath)
} catch (e) {
return { path: normalized, changeType: 'MODIFIED', beforeContent: '', afterContent: '', unavailableReason: e.message || '路径无效' }
}
const absolute = path.resolve(repoRoot, normalized)
if (!absolute.startsWith(repoRoot + path.sep) && absolute !== repoRoot) {
return { path: normalized, changeType: 'MODIFIED', beforeContent: '', afterContent: '', unavailableReason: '路径访问被拒绝' }
}
const before = await runGit(repoRoot, ['show', `${normalizedHash}^:${beforePath}`])
const after = await runGit(repoRoot, ['show', `${normalizedHash}:${normalized}`])
const beforeContent = before.exitCode === 0 ? before.stdout : null
const afterContent = after.exitCode === 0 ? after.stdout : null
const changeType = beforeContent == null && afterContent != null
? 'CREATED'
: beforeContent != null && afterContent == null
? 'DELETED'
: beforePath !== normalized ? 'RENAMED' : 'MODIFIED'
if (beforeContent != null && beforeContent.includes('\0')) {
return { path: normalized, changeType, beforeContent: '', afterContent: '', binary: true, unavailableReason: '二进制文件,无法预览' }
}
if (afterContent != null && afterContent.includes('\0')) {
return { path: normalized, changeType, beforeContent: '', afterContent: '', binary: true, unavailableReason: '二进制文件,无法预览' }
}
const beforeTrunc = truncateText(beforeContent == null ? '' : beforeContent)
const afterTrunc = truncateText(afterContent == null ? '' : afterContent)
return {
path: normalized,
changeType,
beforeContent: beforeTrunc.content,
afterContent: afterTrunc.content,
truncated: beforeTrunc.truncated || afterTrunc.truncated,
}
}
/**
* 分支名合法性(git check-ref-format 子集,只读侧的安全边界)。
*
* 规则与后端只读侧 `backend-ts/src/file/workspace-git.service.ts` 的同名实现互指(同一份规则
* 双端各一份,fixture 对跑;本文件是 .cjs、无法 import TS 工具模块):字符类
* `^[A-Za-z0-9._/][A-Za-z0-9._/-]*$`(首字符白名单里没有 `-`,杜绝 `-x` 被 git 当选项——
* spawn 参数数组不过 shell 挡不住这一层)、长度 ≤ 200、非 `@`、无 `..`、无 `@{`、
* 不以 `/` 开头或结尾、无 `//`、不以 `.` 结尾、每个 `/` 组件非空且不以 `.` 开头、不以 `.lock` 结尾。
*
* 注意:本规则**严于**写侧的 `BRANCH_NAME_PATTERN`(`git-write-operation.service.ts`)与前端
* `desktop/src/utils/git-manager.ts` 的 isValidBranchName——那两处字符类以 `+` 收尾、首字符并不拒 `-`
* (实测 `-x` 可通过),本方案只读侧用更严正则,勿照抄现状。字符类仍窄于 check-ref-format
* (如 `+` 合法但被拒),与写侧同款的已知限制,下拉选分支时属于可忽略极端。
* @param {string} name
* @returns {boolean}
*/
function isValidBranchName(name) {
const value = String(name == null ? '' : name)
if (!value || value.length > 200) return false
if (!/^[A-Za-z0-9._/][A-Za-z0-9._/-]*$/.test(value)) return false
if (value === '@') return false
if (value.includes('..') || value.includes('@{')) return false
if (value.startsWith('/') || value.endsWith('/') || value.includes('//') || value.endsWith('.')) return false
return value.split('/').every((part) => part.length > 0 && !part.startsWith('.') && !part.endsWith('.lock'))
}
/**
* 分支名 → 对象 sha(**显式 ref 路径 + 两段式**解析,杜绝同名 tag 遮蔽分支)。
*
* 为什么不能用 `rev-parse --verify <name>`:git 的 refname 解析顺序是
* `<name>` → `refs/<name>` → `refs/tags/<name>` → `refs/heads/<name>` → `refs/remotes/<name>`,
* **tag 先于 heads**。仓库里存在与分支同名的 tag 时,`--verify <name>` / `diff <name> <name>` 这类
* 裸名字比较实际比的是 tag——用户下拉里选的是分支、结果比的却是 tag,是静默错误
* (且 git 还会往 stderr 打 `refname 'x' is ambiguous` 警告)。故这里先按 `refs/heads/<name>`(本地分支)
* 显式取,失败再试 `refs/remotes/<name>`(下拉里的 `origin/x` 远端跟踪分支形态;
* 两段都是完整 ref 路径,同名 tag 不可能命中)。
*
* 两段都失败 → 返回 null(调用方按各自契约映射「分支不存在」:compare 抛错、file-diff 返回降级对象)。
* 输出必须是对象 id(40 位 SHA-1 / 64 位 SHA-256 的 hex);取不到(空输出 / 非 hex)同样视为未解析,
* 因为后续命令一律用 sha 拼接,拿脏字符串进参数数组就是另一类静默错误。
* @param {string} repoRoot
* @param {string} name
* @returns {Promise<string|null>}
*/
async function resolveBranchSha(repoRoot, name) {
for (const ref of [`refs/heads/${name}`, `refs/remotes/${name}`]) {
const result = await runGit(repoRoot, ['rev-parse', '--verify', ref])
if (result.exitCode !== 0) continue
const sha = result.stdout.trim()
if (/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(sha)) return sha
}
return null
}
/**
* 两个分支**当前内容**的全部差异(两点 `<base> <target>` 语义,方向 base → target)
* + 目标分支相对基准的领先/落后提交数。命令序列:
* ① 两个分支名各自两段式解析成 sha(`refs/heads/` → `refs/remotes/`,见 resolveBranchSha)
* ② rev-list --left-right --count ③ diff --name-status ④ diff --numstat,
* ②–④ 一律传解析出的 sha(不再传分支名,故同名 tag 的 `refname 'x' is ambiguous` 从根上不会出现)。
*
* 为什么清单是两点而非三点 `<base>...<target>`:三点只列「目标相对 merge-base 的独有改动」,
* 目标完全落后 / 双方分叉时基准侧独有的改动会整体消失(用户看不到反向差异)。
* 两点直接比较两棵树:base 有而 target 无 → D、target 有而 base 无 → A、内容不同 → M,
* 基准侧独有改动以反向修改/删除的形式出现,属固有行为、不做排除。
* 计数仍用 `rev-list --left-right --count <base>...<target>`(left = base 独有 = behind、
* right = target 独有 = ahead),与清单口径不同是刻意的:计数答「差几个提交」、清单答「内容差在哪」。
* 两点 diff 与上述 rev-list 对 unrelated histories 同样可用,故不再调用 merge-base、也不再判「无共同祖先」。
*
* 两处易错点:
* - **失败 ≠ 空差异**:`git diff A B` 无差异时输出为空且退出码 0,与「ref 不存在」必须可区分,
* 故先解析 ref 再 diff(顺序不可颠倒),且 rev-list / diff 一律走 `runGit` 读 `exitCode`;
* `runGitOk` 把非零 / 超时 / maxBuffer 溢出统一折成 null,会把这些失败误报成「没有差异」。
* - **rev-list 的 `...` 不是三点 diff**:`rev-list --left-right --count A...B` 只做可达性集合运算,
* 无共同祖先也能成功(有别于 `git diff A...B` 的 `fatal: ... no merge base` 报错)。
*
* 抛错形态与 `getGitCommitFiles` 一致(`Error`,main.cjs 的 catch 转成 `{ error }`)。
* 返回的 `base` / `target` 是用户传入的原始分支名(sha 只用于命令,不出现在结果里)。
* @param {string} workspace
* @param {string} [repoPath]
* @param {string} base 基准分支
* @param {string} target 目标分支
* @returns {Promise<{base: string, target: string, aheadCount: number, behindCount: number, files: object[]}>}
*/
async function getGitBranchCompare(workspace, repoPath, base, target) {
const baseRef = String(base == null ? '' : base).trim()
const targetRef = String(target == null ? '' : target).trim()
// 分支名不合法与 ref 不存在同样映射「分支不存在」(与后端 PARAM_INVALID 文案一致)
if (!isValidBranchName(baseRef) || !isValidBranchName(targetRef)) throw new Error('分支不存在')
const repoRoot = await resolveRepoRoot(workspace, repoPath)
// 分支名 → sha 用显式 ref 路径两段式解析(见 resolveBranchSha:同名 tag 会遮蔽分支),
// 两段都失败 → 与 ref 不存在同文案;随后所有 git 命令一律用 sha,不再出现分支名歧义
const baseSha = await resolveBranchSha(repoRoot, baseRef)
if (!baseSha) throw new Error('分支不存在')
const targetSha = await resolveBranchSha(repoRoot, targetRef)
if (!targetSha) throw new Error('分支不存在')
// 计数仍是三点集合运算:left = 基准独有(目标落后数),right = 目标独有(目标领先数)
const counts = await runGit(repoRoot, ['rev-list', '--left-right', '--count', `${baseSha}...${targetSha}`])
if (counts.exitCode !== 0) throw new Error('对比失败,请重试')
// 输出 `left\tright`
const countParts = counts.stdout.trim().split(/\s+/)
const behindCount = parseInt(countParts[0], 10) || 0
const aheadCount = parseInt(countParts[1], 10) || 0
// 清单是两点 diff:两个 sha 空格分隔(**不是** `<a>...<b>`),base 侧独有改动以 D / 反向 M 出现
const nameStatus = await runGit(repoRoot, ['diff', '--name-status', baseSha, targetSha])
if (nameStatus.exitCode !== 0) throw new Error('对比失败,请重试')
const numstat = await runGit(repoRoot, ['diff', '--numstat', baseSha, targetSha])
if (numstat.exitCode !== 0) throw new Error('对比失败,请重试')
// 以下合并逻辑逐行复刻 getGitCommitFiles(差异仅在命令:`show <hash>` → `diff <base> <target>`):
// name-status 建 Map(rename 键为新路径)→ numstat 用 numstatNewPath 还原后回填行数 → `-` 判二进制
const files = new Map()
if (nameStatus.stdout && nameStatus.stdout.trim()) {
for (const raw of nameStatus.stdout.split('\n')) {
const line = raw.trim()
if (!line) continue
const file = parseNameStatusLine(line)
if (file) files.set(file.path, { insertions: 0, deletions: 0, ...file })
}
}
if (numstat.stdout && numstat.stdout.trim()) {
for (const raw of numstat.stdout.split('\n')) {
const line = raw.trim()
if (!line) continue
const parts = line.split('\t')
if (parts.length < 3) continue
// numstat 的改名是紧凑形态(src/{a.txt => b.txt}),必须用 numstatNewPath 还原才能与 name-status 键对齐
const filePath = numstatNewPath(parts[parts.length - 1])
const file = files.get(filePath)
if (!file) continue
if (parts[0] === '-' || parts[1] === '-') {
file.binary = true
file.insertions = 0
file.deletions = 0
} else {
file.insertions = parseInt(parts[0], 10) || 0
file.deletions = parseInt(parts[1], 10) || 0
}
}
}
return { base: baseRef, target: targetRef, aheadCount, behindCount, files: Array.from(files.values()) }
}
/**
* 分支对比的单文件 diff:before = `git show <baseSha>:<oldPath ?? path>`(**base tip**,不再是 merge-base),
* after = `git show <targetSha>:<path>`(均取自 git 对象、不读工作区;与 `getGitCommitFileDiff` 同结构,
* 只把 `<hash>^:` / `<hash>:` 换成 `<base 分支的 sha>:` / `<target 分支的 sha>:`)。两个分支名先用
* resolveBranchSha 两段式解析成 sha(显式 `refs/heads/` → `refs/remotes/`,同名 tag 不会遮蔽分支)。
*
* before 取 base tip 与文件清单的两点 diff 同口径:双方分叉且都改同一文件时,before 是基准分支的内容、
* 基准侧的独有改动会以反向修改/删除的形式出现在同一个 tab 里,与清单自洽(不再需要 merge-base)。
* rename 的 oldPath 是 base 侧旧路径(清单 R 行的旧路径);base 侧已无该路径时 before 取空、
* 退化成 CREATED——两棵树直接比较的固有结果(该场景清单本身只会给出 A 新路径,不会带 oldPath)。
*
* 失败语义按 `getGitCommitFileDiff` 现状复刻:输入类失败(非法分支名 / 路径 `..` / 越界)返回带
* `unavailableReason` 的降级对象、不 throw;对象缺失(show 非零退出)不算错误——before 缺失按空内容
* 处理、由 CREATED/DELETED 推导表达;二进制 → `unavailableReason`;超限 → `truncated`;
* 真超时(非 maxBuffer 溢出)→ `'对比失败,请重试'`。返回的 `path` 恒为新路径(调用方用它生成 tab id / 标题)。
* @param {string} workspace
* @param {string} [repoPath]
* @param {string} base 基准分支
* @param {string} target 目标分支
* @param {string} relativePath 新路径(文件清单的 path)
* @param {string} [oldPath] rename 行的旧路径(base 侧旧路径)
* @returns {Promise<object>}
*/
async function getGitBranchCompareFileDiff(workspace, repoPath, base, target, relativePath, oldPath) {
const baseRef = String(base == null ? '' : base).trim()
const targetRef = String(target == null ? '' : target).trim()
const degraded = (reason, degradedPath) => ({
path: degradedPath,
changeType: 'MODIFIED',
beforeContent: '',
afterContent: '',
unavailableReason: reason,
})
if (!isValidBranchName(baseRef) || !isValidBranchName(targetRef)) {
return degraded('分支不存在', relativePath || '')
}
if (!relativePath || relativePath.replace(/\\/g, '/').split('/').includes('..')) {
return degraded('路径无效', relativePath || '')
}
const normalized = relativePath.replace(/\\/g, '/').replace(/^\.\//, '')
const beforePath = oldPath == null || String(oldPath).trim().length === 0
? normalized
: String(oldPath).replace(/\\/g, '/').replace(/^\.\//, '')
if (beforePath.split('/').includes('..')) {
return degraded('路径无效', normalized)
}
let repoRoot
try {
repoRoot = await resolveRepoRoot(workspace, repoPath)
} catch (e) {
return degraded(e.message || '路径无效', normalized)
}
const absolute = path.resolve(repoRoot, normalized)
if (!absolute.startsWith(repoRoot + path.sep) && absolute !== repoRoot) {
return degraded('路径访问被拒绝', normalized)
}
// 显式 ref 路径两段式解析(见 resolveBranchSha:同名 tag 会遮蔽分支),后续命令一律用 sha
const baseSha = await resolveBranchSha(repoRoot, baseRef)
if (!baseSha) return degraded('分支不存在', normalized)
const targetSha = await resolveBranchSha(repoRoot, targetRef)
if (!targetSha) return degraded('分支不存在', normalized)
// 两点语义:before 取 base tip、after 取 target tip(不再有 merge-base 步骤)
const before = await runGit(repoRoot, ['show', `${baseSha}:${beforePath}`])
const after = await runGit(repoRoot, ['show', `${targetSha}:${normalized}`])
// 真超时 ≠「文件不存在」:runGit 的 timeout 走 kill 分支、err.code 非数字,exitCode 会被折成 1,
// 与 show 失败(对象缺失)撞码,不能静默渲染成空 diff;maxBuffer 溢出(overflowed,stdout 是预算内
// 已收集内容)不属于超时,走下面的「成功 + 截断」分支,故显式排除。
if ((before.timedOut && !before.overflowed) || (after.timedOut && !after.overflowed)) {
return degraded('对比失败,请重试', normalized)
}
// >2 MiB 大文件:runGit 因 maxBuffer 溢出返回 overflowed(stdout 是预算内已收集的 2 MiB),
// 按「成功 + 截断」继续交给 truncateText,与 CLOUD(runGitRaw 保留预算内输出 → truncated)
// 行为一致(方案 §5.5 / §2.1);否则会静默退化成空 diff、且不标记 truncated。
const beforeContent = before.exitCode === 0 || before.overflowed ? before.stdout : null
const afterContent = after.exitCode === 0 || after.overflowed ? after.stdout : null
const changeType = beforeContent == null && afterContent != null
? 'CREATED'
: beforeContent != null && afterContent == null
? 'DELETED'
: beforePath !== normalized ? 'RENAMED' : 'MODIFIED'
if (beforeContent != null && beforeContent.includes('\0')) {
return { path: normalized, changeType, beforeContent: '', afterContent: '', binary: true, unavailableReason: '二进制文件,无法预览' }
}
if (afterContent != null && afterContent.includes('\0')) {
return { path: normalized, changeType, beforeContent: '', afterContent: '', binary: true, unavailableReason: '二进制文件,无法预览' }
}
const beforeTrunc = truncateText(beforeContent == null ? '' : beforeContent)
const afterTrunc = truncateText(afterContent == null ? '' : afterContent)
return {
path: normalized,
changeType,
beforeContent: beforeTrunc.content,
afterContent: afterTrunc.content,
truncated: beforeTrunc.truncated || afterTrunc.truncated,
}
}
/**
* 分支列表:本地(git branch -vv)+ 远端(git branch -r -vv),与后端 getBranches 同解析。
* @param {string} workspace
* @param {string} [repoPath]
* @returns {Promise<object[]>}
*/
async function getGitBranches(workspace, repoPath) {
const repoRoot = await resolveRepoRoot(workspace, repoPath)
const [localOut, remoteOut, headOut, unbornOut] = await Promise.all([
runGitOk(repoRoot, ['branch', '-vv']),
runGitOk(repoRoot, ['branch', '-r', '-vv']),
runGitOk(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']),
// 空仓库(未出生 HEAD)下 git branch -vv 无任何输出,用 symbolic-ref 补「即将出生的当前分支」
runGitOk(repoRoot, ['symbolic-ref', '--short', 'HEAD']),
])
const currentBranch = headOut ? headOut.trim() : ''
const branches = []
for (const line of (localOut || '').split('\n')) {
const parsed = parseBranchVvLine(line)
// detached 行形如 '* (HEAD detached at <hash>) <subject>',不是分支,跳过
if (!parsed || parsed.name.startsWith('(')) continue
branches.push({
name: parsed.name,
isCurrent: parsed.isCurrent || (currentBranch.length > 0 && currentBranch === parsed.name),
isRemote: false,
upstream: parsed.upstream,
ahead: parsed.ahead,
behind: parsed.behind,
lastCommitHash: parsed.lastCommitHash,
lastCommitTitle: parsed.lastCommitTitle,
})
}
if (branches.length === 0 && unbornOut && unbornOut.trim().length > 0) {
branches.push({
name: unbornOut.trim(),
isCurrent: true,
isRemote: false,
lastCommitHash: undefined,
lastCommitTitle: undefined,
})
}
for (const line of (remoteOut || '').split('\n')) {
// 跳过 origin/HEAD -> origin/main 符号行
if (line.includes('->')) continue
const parsed = parseBranchVvLine(line)
if (!parsed || parsed.name.startsWith('(')) continue
branches.push({
name: parsed.name,
isCurrent: false,
isRemote: true,
lastCommitHash: parsed.lastCommitHash,
lastCommitTitle: parsed.lastCommitTitle,
})
}
return branches
}
/**
* stash 列表(%gd 索引 / %gs 消息 / %aI 时间)。
* @param {string} workspace
* @param {string} [repoPath]
* @returns {Promise<object[]>}
*/
async function getGitStashes(workspace, repoPath) {
const repoRoot = await resolveRepoRoot(workspace, repoPath)
const out = await runGitOk(repoRoot, ['stash', 'list', '--format=%gd%x00%gs%x00%aI'])
if (!out || !out.trim()) return []
const entries = []
for (const line of out.split('\n')) {
if (!line) continue
const parts = line.split('\0')
const ref = (parts[0] || '').trim()
if (!ref) continue
entries.push({ ref, message: parts[1] || '', date: (parts[2] || '').trim() })
}
return entries
}
/**
* 远端列表(fetch/push URL 合并为一条;URL 过 sanitizeRemoteError 脱敏,与后端 CREDENTIAL_URL 同语义)。
* @param {string} workspace
* @param {string} [repoPath]
* @returns {Promise<object[]>}
*/
async function getGitRemotes(workspace, repoPath) {
const repoRoot = await resolveRepoRoot(workspace, repoPath)
const out = await runGitOk(repoRoot, ['remote', '-v'])
if (!out || !out.trim()) return []
const merged = new Map()
for (const line of out.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed)
if (!match) continue
const [, name, url, kind] = match
const entry = merged.get(name) || {}
if (kind === 'fetch') entry.fetch = url
else entry.push = url
merged.set(name, entry)
}
const remotes = []
for (const [name, urls] of merged) {
remotes.push({
name,
fetchUrl: sanitizeRemoteError(urls.fetch || urls.push || ''),
pushUrl: sanitizeRemoteError(urls.push || urls.fetch || ''),
})
}
return remotes
}
module.exports = {
EXCLUDED_REPO_DIRS,
collectChangedFiles,
getGitBranches,
getGitBranchCompare,
getGitBranchCompareFileDiff,
getGitCommitFileDiff,
getGitCommitFiles,
getGitFileDiff,
getGitLog,
getGitRemotes,
getGitStashes,
getGitStatus,
isValidBranchName,
listGitRepos,
numstatNewPath,
parseBranchVvLine,
parseLogLine,
refreshGitStatus,
resolveRepoDir,
resolveRepoRoot,
sanitizeRemoteError,
selectFetchRemote,
}