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} */ 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} */ 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} fn * @returns {Promise} */ 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} */ 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} */ 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 ^:`,after = `git show :`(均取自 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} */ 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 `:git 的 refname 解析顺序是 * `` → `refs/` → `refs/tags/` → `refs/heads/` → `refs/remotes/`, * **tag 先于 heads**。仓库里存在与分支同名的 tag 时,`--verify ` / `diff ` 这类 * 裸名字比较实际比的是 tag——用户下拉里选的是分支、结果比的却是 tag,是静默错误 * (且 git 还会往 stderr 打 `refname 'x' is ambiguous` 警告)。故这里先按 `refs/heads/`(本地分支) * 显式取,失败再试 `refs/remotes/`(下拉里的 `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} */ 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) * + 目标分支相对基准的领先/落后提交数。命令序列: * ① 两个分支名各自两段式解析成 sha(`refs/heads/` → `refs/remotes/`,见 resolveBranchSha) * ② rev-list --left-right --count ③ diff --name-status ④ diff --numstat, * ②–④ 一律传解析出的 sha(不再传分支名,故同名 tag 的 `refname 'x' is ambiguous` 从根上不会出现)。 * * 为什么清单是两点而非三点 `...`:三点只列「目标相对 merge-base 的独有改动」, * 目标完全落后 / 双方分叉时基准侧独有的改动会整体消失(用户看不到反向差异)。 * 两点直接比较两棵树:base 有而 target 无 → D、target 有而 base 无 → A、内容不同 → M, * 基准侧独有改动以反向修改/删除的形式出现,属固有行为、不做排除。 * 计数仍用 `rev-list --left-right --count ...`(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 空格分隔(**不是** `...`),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 ` → `diff `): // 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 :`(**base tip**,不再是 merge-base), * after = `git show :`(均取自 git 对象、不读工作区;与 `getGitCommitFileDiff` 同结构, * 只把 `^:` / `:` 换成 `:` / `:`)。两个分支名先用 * 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} */ 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} */ 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 ) ',不是分支,跳过 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} */ 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} */ 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, }