{"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\utils\\createSavePortToFile.js","old_string":"import logger from \u0027../utils/logger.js\u0027","new_string":"import logger from \u0027./logger.js\u0027","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\utils\\createSavePortToFile.js","oldString":"import logger from \u0027../utils/logger.js\u0027","newString":"import logger from \u0027./logger.js\u0027","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\nimport logger from \u0027../utils/logger.js\u0027\n\nexport function createSavePortToFile({ savePort, fs, path, cwdFn = process.cwd }) {\n let savedPort = null;\n\n return async function savePortToFile(port) {\n try {\n if (savePort \u0026\u0026 savedPort !== port) {\n savedPort = port;\n\n const portFilePath = path.join(cwdFn(), \u0027.port\u0027);\n await fs.writeFile(portFilePath, port.toString(), \u0027utf8\u0027);\n logger.info(`端口号 ${port} 已保存到 ${portFilePath}`);\n\n try {\n const clientPath = path.join(cwdFn(), \u0027src\u0027, \u0027ui\u0027, \u0027client\u0027);\n const envPath = path.join(clientPath, \u0027.env.local\u0027);\n\n await fs.access(clientPath).catch(() =\u003e {\n logger.info(`客户端目录 ${clientPath} 不存在,跳过环境变量设置`);\n return Promise.reject(new Error(\u0027Client directory not found\u0027));\n });\n\n await fs.writeFile(envPath, `VITE_BACKEND_PORT=${port}\\n`, \u0027utf8\u0027);\n logger.info(`端口号环境变量已保存到 ${envPath}`);\n } catch (envError) {\n logger.error(\u0027保存端口号到环境变量失败,但不影响主要功能:\u0027, envError);\n }\n }\n } catch (error) {\n logger.error(\u0027保存端口号到文件失败:\u0027, error);\n }\n };\n}\n","structuredPatch":[{"oldStart":12,"oldLines":7,"newStart":12,"newLines":7,"lines":[" // See the License for the specific language governing permissions and"," // limitations under the License."," //","-import logger from \u0027../utils/logger.js\u0027","+import logger from \u0027./logger.js\u0027"," "," export function createSavePortToFile({ savePort, fs, path, cwdFn = process.cwd }) {"," let savedPort = null;"]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c446ad7e21b86f5f01","duration_ms":6,"timestamp":"2026-06-27 09:49:16"} {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\utils\\instanceRegistry.js","old_string":"import logger from \u0027../utils/logger.js\u0027","new_string":"import logger from \u0027./logger.js\u0027","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\utils\\instanceRegistry.js","oldString":"import logger from \u0027../utils/logger.js\u0027","newString":"import logger from \u0027./logger.js\u0027","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\n// 实例注册表工具\n// 维护 ~/.zen-gitsync-instances.json,记录所有正在运行的 GUI 实例\n// 多进程并发写采用 atomic temp+rename + 进程内串行化 Promise 链\n// stale 判定:PID 不存在 或 lastHeartbeat 超过阈值\n\nimport nodePath from \u0027node:path\u0027;\nimport logger from \u0027../utils/logger.js\u0027\nimport nodeOs from \u0027node:os\u0027;\n\nconst STALE_MS = 30_000; // 心跳超时阈值(毫秒)\nconst WATCH_DEBOUNCE_MS = 100; // fs.watch 防抖时间\nconst REGISTRY_VERSION = 1;\n\n/**\n * 默认注册表文件路径(与 createInstanceRegistry({ registryPath }) 的约定一致)\n * 用于:npm.js 的 /api/app-restart 自拉起时,父进程轮询此路径判断子进程是否就绪。\n */\nexport function getRegistryPath() {\n return nodePath.join(nodeOs.homedir(), \u0027.zen-gitsync-instances.json\u0027);\n}\n\nfunction isProcessAlive(pid) {\n if (!Number.isInteger(pid) || pid \u003c= 0) return false;\n try {\n // 信号 0 仅做存活检查,不真正发送信号\n process.kill(pid, 0);\n return true;\n } catch (err) {\n if (err \u0026\u0026 (err.code === \u0027ESRCH\u0027 || err.code === \u0027ENOENT\u0027)) {\n return false;\n }\n // EPERM 等情况:进程存在但权限不足,按 alive 处理\n return true;\n }\n}\n\n// 解析项目名:优先 package.json.name,兜底为目录 basename\nasync function resolveProjectName(projectPath, fsMod, pathMod) {\n if (!projectPath) return \u0027\u0027;\n try {\n const pkgPath = pathMod.join(projectPath, \u0027package.json\u0027);\n const raw = await fsMod.readFile(pkgPath, \u0027utf-8\u0027);\n const pkg = JSON.parse(raw);\n if (pkg \u0026\u0026 typeof pkg.name === \u0027string\u0027 \u0026\u0026 pkg.name.trim()) {\n return pkg.name.trim();\n }\n } catch (_) {\n // 读失败或解析失败,兜底\n }\n return pathMod.basename(projectPath);\n}\n\nexport function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, registryPath }) {\n if (!fsMod || !pathMod || !osMod || !registryPath) {\n throw new Error(\u0027createInstanceRegistry: 必须提供 fs/path/os/registryPath\u0027);\n }\n\n // 进程内写串行化:所有 mutate 操作都 await 这条链\n let writeChain = Promise.resolve();\n\n function enqueueWrite(task) {\n const next = writeChain.then(task, task);\n // 不让单个失败阻塞后续操作\n writeChain = next.catch(() =\u003e {});\n return next;\n }\n\n async function readAll() {\n try {\n const raw = await fsMod.readFile(registryPath, \u0027utf-8\u0027);\n const parsed = JSON.parse(raw);\n if (parsed \u0026\u0026 typeof parsed === \u0027object\u0027 \u0026\u0026 parsed.instances \u0026\u0026 typeof parsed.instances === \u0027object\u0027) {\n return parsed;\n }\n return { version: REGISTRY_VERSION, instances: {} };\n } catch (err) {\n if (err \u0026\u0026 err.code === \u0027ENOENT\u0027) {\n return { version: REGISTRY_VERSION, instances: {} };\n }\n logger.warn(`[instanceRegistry] 读取注册表失败,按空表处理: ${err?.message || err}`);\n return { version: REGISTRY_VERSION, instances: {} };\n }\n }\n\n async function writeAll(obj) {\n const payload = {\n version: REGISTRY_VERSION,\n ...obj,\n instances: obj.instances || {}\n };\n const tmpPath = `${registryPath}.tmp`;\n await fsMod.writeFile(tmpPath, JSON.stringify(payload, null, 2), \u0027utf-8\u0027);\n await fsMod.rename(tmpPath, registryPath);\n }\n\n // 同步裁剪:传入当前内存中的 instances 字典,返回裁剪后的新字典\n function pruneInPlace(instances) {\n const now = Date.now();\n const result = {};\n for (const [pidStr, entry] of Object.entries(instances)) {\n if (!entry || typeof entry !== \u0027object\u0027) continue;\n const pid = Number(entry.pid ?? Number(pidStr));\n if (!isProcessAlive(pid)) continue;\n if (typeof entry.lastHeartbeat === \u0027number\u0027 \u0026\u0026 now - entry.lastHeartbeat \u003e STALE_MS) continue;\n result[pidStr] = entry;\n }\n return result;\n }\n\n // 公开 API\n async function register({ pid, port, projectPath, projectName, hostname } = {}) {\n if (!Number.isInteger(pid) || pid \u003c= 0) throw new Error(\u0027register: pid 必填\u0027);\n if (!Number.isInteger(port) || port \u003c= 0) throw new Error(\u0027register: port 必填\u0027);\n if (!projectPath) throw new Error(\u0027register: projectPath 必填\u0027);\n\n const resolvedName = projectName \u0026\u0026 String(projectName).trim()\n ? String(projectName).trim()\n : await resolveProjectName(projectPath, fsMod, pathMod);\n\n const entry = {\n pid,\n port,\n projectName: resolvedName,\n projectPath,\n startedAt: Date.now(),\n lastHeartbeat: Date.now(),\n hostname: hostname || osMod.hostname()\n };\n\n await enqueueWrite(async () =\u003e {\n const obj = await readAll();\n obj.instances[String(pid)] = entry;\n await writeAll(obj);\n });\n return entry;\n }\n\n async function unregister(pid) {\n if (!Number.isInteger(pid) || pid \u003c= 0) return;\n await enqueueWrite(async () =\u003e {\n const obj = await readAll();\n if (obj.instances \u0026\u0026 obj.instances[String(pid)]) {\n delete obj.instances[String(pid)];\n await writeAll(obj);\n }\n });\n }\n\n async function heartbeat(pid, updates = {}) {\n if (!Number.isInteger(pid) || pid \u003c= 0) return;\n await enqueueWrite(async () =\u003e {\n const obj = await readAll();\n const key = String(pid);\n const existing = obj.instances[key];\n if (!existing) {\n // 如果心跳时条目不存在(被裁剪或被外部清理),跳过;\n // 由调用方负责周期性 re-register\n return;\n }\n obj.instances[key] = {\n ...existing,\n ...(updates.projectPath ? { projectPath: updates.projectPath } : {}),\n ...(updates.projectName ? { projectName: updates.projectName } : {}),\n lastHeartbeat: Date.now()\n };\n await writeAll(obj);\n });\n }\n\n async function list({ pruneStale = true } = {}) {\n const obj = await readAll();\n let instances = obj.instances || {};\n if (pruneStale) {\n instances = pruneInPlace(instances);\n // 如果发生了裁剪,持久化回去\n const hasChange = Object.keys(instances).length !== Object.keys(obj.instances || {}).length;\n if (hasChange) {\n await enqueueWrite(async () =\u003e {\n const fresh = await readAll();\n fresh.instances = pruneInPlace(fresh.instances || {});\n await writeAll(fresh);\n });\n }\n }\n const arr = Object.values(instances);\n arr.sort((a, b) =\u003e (a.port || 0) - (b.port || 0));\n return arr;\n }\n\n // 监听注册表文件变化;callback 会在 debounce 后被调用,参数是最新 list\n // fsWatch 参数:node \u0027fs\u0027 模块的 watch 函数(同步 + EventEmitter 形式)\n function watch(callback, fsWatch) {\n if (typeof callback !== \u0027function\u0027) {\n throw new Error(\u0027watch: callback 必填\u0027);\n }\n if (typeof fsWatch !== \u0027function\u0027) {\n logger.warn(\u0027[instanceRegistry] 未提供 fs.watch,跨进程推送将不可用\u0027);\n return function noop() {};\n }\n let debounceTimer = null;\n let watcher = null;\n let pruneTimer = null;\n let closed = false;\n\n const fire = async () =\u003e {\n if (closed) return;\n try {\n const fresh = await list({ pruneStale: true });\n callback(fresh);\n } catch (e) {\n logger.warn(`[instanceRegistry] watch 回调失败: ${e?.message || e}`);\n }\n };\n\n // 周期性 prune:即便没有其他进程写入注册表,也定期清理本地失效条目\n // (例如所有 server 都强 kill 后,文件里残留的僵尸条目会被自动清理)\n pruneTimer = setInterval(() =\u003e {\n if (closed) return;\n list({ pruneStale: true }).catch(() =\u003e {});\n }, STALE_MS / 3);\n\n try {\n watcher = fsWatch(registryPath, { persistent: false }, () =\u003e {\n if (closed) return;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(fire, WATCH_DEBOUNCE_MS);\n });\n if (watcher \u0026\u0026 typeof watcher.on === \u0027function\u0027) {\n watcher.on(\u0027error\u0027, (err) =\u003e {\n logger.warn(`[instanceRegistry] fs.watch 出错: ${err?.message || err}`);\n });\n }\n } catch (err) {\n logger.warn(`[instanceRegistry] 无法启动 fs.watch (${err?.message || err}),跨进程推送将不可用,请依赖轮询`);\n }\n\n return function closeWatcher() {\n closed = true;\n if (debounceTimer) {\n clearTimeout(debounceTimer);\n debounceTimer = null;\n }\n if (pruneTimer) {\n clearInterval(pruneTimer);\n pruneTimer = null;\n }\n if (watcher) {\n try { watcher.close(); } catch (_) {}\n watcher = null;\n }\n };\n }\n\n function close() {\n // 工厂内部无 timer,仅为 API 对称\n }\n\n return {\n register,\n unregister,\n heartbeat,\n list,\n watch,\n close,\n _resolveProjectName: (p) =\u003e resolveProjectName(p, fsMod, pathMod),\n _STALE_MS: STALE_MS\n };\n}\n","structuredPatch":[{"oldStart":18,"oldLines":7,"newStart":18,"newLines":7,"lines":[" // stale 判定:PID 不存在 或 lastHeartbeat 超过阈值"," "," import nodePath from \u0027node:path\u0027;","-import logger from \u0027../utils/logger.js\u0027","+import logger from \u0027./logger.js\u0027"," import nodeOs from \u0027node:os\u0027;"," "," const STALE_MS = 30_000; // 心跳超时阈值(毫秒)"]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c446ad7e21b86f5f1d","duration_ms":9,"timestamp":"2026-06-27 09:49:16"} {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\diff.js","old_string":"import logger from \u0027../utils/logger.js\u0027","new_string":"import logger from \u0027../../utils/logger.js\u0027","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\diff.js","oldString":"import logger from \u0027../utils/logger.js\u0027","newString":"import logger from \u0027../../utils/logger.js\u0027","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\nimport fs from \u0027fs/promises\u0027;\nimport logger from \u0027../utils/logger.js\u0027\nimport { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;\n\nimport { createDiffHelpers } from \u0027./diffUtils.js\u0027;\n\nimport { ensureWithinCwd } from \u0027../../utils/pathGuard.js\u0027;\n\n/**\n * SEC-PATH-1: resolve user path to be inside process.cwd(), returns null on escape\n * @param {string} userPath\n * @returns {Promise\u003cstring|null\u003e}\n */\nasync function safePathInProject(userPath) {\n if (typeof userPath !== \u0027string\u0027 || !userPath) return null;\n if (/[\\x00-\\x1f]/.test(userPath)) return null;\n const cwd = process.cwd();\n const result = await ensureWithinCwd(userPath, cwd);\n return result ? result.safePath : null;\n}\n\nexport function registerGitDiffRoutes({\n app,\n execGitCommand\n}) {\n const { checkShouldSkipDiff, checkDiffSize, getDiffStats } = createDiffHelpers({ execGitCommand });\n\n const skipExtensions = /\\.(min\\.js|umd\\.cjs|bundle\\.js|dist\\.js|prod\\.js|map|wasm|exe|dll|so|dylib|bin|zip|tar|gz|rar|7z|jar|war|ear|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|bmp|ico|mp3|mp4|avi|mov|wmv|flv|webm|mkv|ttf|woff|woff2|eot|otf)$/i;\n const maxBytes = 1024 * 1024;\n\n // 获取文件差异\n app.get(\u0027/api/diff\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n \n if (!filePath) {\n return res.status(400).json({ error: \u0027缺少文件路径参数\u0027 });\n }\n \n const diffArgs = [\u0027diff\u0027, \u0027--\u0027, filePath];\n \n // 使用优化的检查函数(diffCommand 用于内部 numstat 检测的字符串转换,\n // 实际走 git 时用 argv 数组,避免 Windows cmd.exe 拼引号问题)\n const skipCheck = await checkShouldSkipDiff(filePath, `git diff -- \"${filePath}\"`);\n if (skipCheck.shouldSkip) {\n return res.json({\n diff: skipCheck.reason,\n isLargeFile: true,\n stats: skipCheck.stats\n });\n }\n \n // 执行git diff命令获取文件差异\n const { stdout } = await execGitCommand(diffArgs);\n \n // 检查实际diff大小\n const sizeCheck = checkDiffSize(stdout, 500);\n if (sizeCheck) {\n return res.json(sizeCheck);\n }\n \n // 统计增加和删除行数\n const stats = getDiffStats(stdout);\n \n res.json({ diff: stdout, stats });\n } catch (error) {\n res.status(500).json({ error: error.message });\n }\n }));\n // 获取已暂存文件差异\n app.get(\u0027/api/diff-cached\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n \n if (!filePath) {\n return res.status(400).json({ error: \u0027缺少文件路径参数\u0027 });\n }\n \n const diffArgs = [\u0027diff\u0027, \u0027--cached\u0027, \u0027--\u0027, filePath];\n \n // 使用优化的检查函数\n const skipCheck = await checkShouldSkipDiff(filePath, `git diff --cached -- \"${filePath}\"`);\n if (skipCheck.shouldSkip) {\n return res.json({\n diff: skipCheck.reason,\n isLargeFile: true,\n stats: skipCheck.stats\n });\n }\n \n // 执行git diff --cached命令获取已暂存文件差异\n const { stdout } = await execGitCommand(diffArgs);\n \n // 检查实际diff大小\n const sizeCheck = checkDiffSize(stdout, 500);\n if (sizeCheck) {\n return res.json(sizeCheck);\n }\n \n // 统计增加和删除行数\n const stats = getDiffStats(stdout);\n \n res.json({ diff: stdout, stats });\n } catch (error) {\n res.status(500).json({ error: error.message });\n }\n }));\n\n // 获取全量 diff(git diff HEAD,含已暂存与未暂存的所有变更)\n app.get(\u0027/api/diff-head\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stdout } = await execGitCommand([\u0027diff\u0027, \u0027HEAD\u0027]);\n const MAX = 500 * 1024;\n const content = stdout.length \u003e MAX\n ? stdout.slice(0, MAX) + \u0027\\n\\n[内容过大,已截断]\u0027\n : stdout;\n res.json({ success: true, diff: content });\n } catch (error) {\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 获取文件内容 (用于未跟踪文件)\n app.get(\u0027/api/file-content\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n\n if (!filePath) {\n return res.status(400).json({ error: \u0027缺少文件路径参数\u0027 });\n }\n\n // SEC-PATH-1:resolve 到 cwd 内,越界返回 403\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n try {\n // 二进制/产物文件:直接告知前端 isBinary,不读取内容\n if (skipExtensions.test(safeFilePath)) {\n const isImage = /\\.(png|jpg|jpeg|gif|webp|bmp|ico|svg)$/i.test(safeFilePath);\n return res.json({\n success: true,\n isBinary: true,\n isImage,\n content: isImage\n ? \u0027⚠️ 该文件是图片,建议在预览中查看。\u0027\n : \u0027⚠️ 检测到二进制/编译产物文件,不支持以文本形式显示完整内容。\u0027\n });\n }\n\n // 读取文件内容(走 safePath,不是 user input)\n const content = await fs.readFile(safeFilePath, \u0027utf8\u0027);\n res.json({ success: true, content });\n } catch (readError) {\n res.status(500).json({ success: false, error: `无法读取文件: ${readError.message}` });\n }\n } catch (error) {\n res.status(500).json({ error: error.message });\n }\n }));\n\n app.get(\u0027/api/git-file-content\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n const rev = req.query.rev;\n\n if (!filePath || !rev) {\n throw new HttpError(400, \u0027缺少必要参数\u0027);\n }\n\n // SEC-PATH-1:路径必须在 cwd 内,越界返回 403\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n if (skipExtensions.test(safeFilePath)) {\n return res.json({\n success: true,\n isBinary: true,\n content: \u0027⚠️ 检测到二进制/编译产物文件,不支持以文本形式显示完整内容。\u0027\n });\n }\n\n const r = String(rev);\n // 用 safeFilePath 而不是 user input,防注入\n const spec = r === \u0027:\u0027 ? `:${safeFilePath}` : `${r}:${safeFilePath}`;\n\n let sizeBytes = 0;\n try {\n const { stdout: sizeOut } = await execGitCommand([\u0027cat-file\u0027, \u0027-s\u0027, spec], { log: false });\n sizeBytes = parseInt(String(sizeOut).trim(), 10) || 0;\n } catch (e) {\n return res.json({ success: true, notFound: true, content: \u0027\u0027 });\n }\n\n if (sizeBytes \u003e maxBytes) {\n return res.json({\n success: true,\n isLargeFile: true,\n size: sizeBytes,\n content: `⚠️ 文件内容过大 (${(sizeBytes / 1024).toFixed(1)} KB),已跳过显示以避免浏览器卡顿。`\n });\n }\n\n const { stdout } = await execGitCommand([\u0027show\u0027, spec], { log: false });\n return res.json({ success: true, content: stdout ?? \u0027\u0027 });\n } catch (error) {\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 解决冲突:保存解决后的文件内容\n app.post(\u0027/api/resolve-conflict\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { filePath, content } = req.body;\n\n if (!filePath) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少文件路径参数\u0027\n });\n }\n\n // SEC-PATH-1:resolve 到 cwd 内,越界 403\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n if (content === undefined) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少文件内容参数\u0027\n });\n }\n\n try {\n // 写入解决后的内容到文件(走 safePath)\n await fs.writeFile(safeFilePath, content, \u0027utf8\u0027);\n\n res.json({\n success: true,\n message: \u0027冲突已解决,文件已更新\u0027\n });\n } catch (writeError) {\n res.status(500).json({\n success: false,\n error: `保存文件失败: ${writeError.message}`\n });\n }\n } catch (error) {\n logger.error(\u0027解决冲突失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `解决冲突失败: ${error.message}`\n });\n }\n }));\n\n // 撤回文件修改\n app.post(\u0027/api/revert_file\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { filePath } = req.body;\n\n if (!filePath) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少文件路径参数\u0027\n });\n }\n\n // SEC-PATH-1:resolve 到 cwd 内\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n // 检查文件状态:未跟踪文件需要删除,修改文件需要恢复\n const { stdout: statusOutput } = await execGitCommand([\u0027status\u0027, \u0027--porcelain\u0027, \u0027--\u0027, safeFilePath]);\n\n // 未跟踪的文件 (??), 需要删除它\n if (statusOutput.startsWith(\u0027??\u0027)) {\n try {\n await fs.unlink(safeFilePath);\n return res.json({ success: true, message: \u0027未跟踪的文件已删除\u0027 });\n } catch (error) {\n return res.status(500).json({\n success: false,\n error: `删除文件失败: ${error.message}`\n });\n }\n }\n // 已暂存的文件,先取消暂存\n else if (statusOutput.startsWith(\u0027A \u0027) || statusOutput.startsWith(\u0027M \u0027) || statusOutput.startsWith(\u0027D \u0027)) {\n // 先取消暂存\n await execGitCommand([\u0027reset\u0027, \u0027HEAD\u0027, \u0027--\u0027, safeFilePath]);\n }\n\n // 已修改文件,取消所有本地修改\n if (statusOutput) {\n await execGitCommand([\u0027checkout\u0027, \u0027--\u0027, safeFilePath]);\n return res.json({ success: true, message: \u0027文件修改已撤回\u0027 });\n } else {\n return res.status(400).json({\n success: false,\n error: \u0027文件没有修改或不存在\u0027\n });\n }\n } catch (error) {\n logger.error(\u0027撤回文件修改失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `撤回文件修改失败: ${error.message}`\n });\n }\n }));\n\n // 批量撤回文件修改(未跟踪删除,已修改 checkout 还原)\n // body: { filePaths: string[] }\n // 返回: { success, count, results: [{ path, success, error? }] }\n app.post(\u0027/api/revert_files\u0027, asyncRoute(async (req, res) =\u003e {\n const filePaths = Array.isArray(req.body?.filePaths) ? req.body.filePaths : []\n if (filePaths.length === 0) {\n return res.status(400).json({ success: false, error: \u0027缺少文件路径参数\u0027 })\n }\n\n // 批量大小限制,防止 DoS\n const MAX_BATCH = 200;\n if (filePaths.length \u003e MAX_BATCH) {\n return res.status(400).json({ success: false, error: `批量大小不能超过 ${MAX_BATCH}` });\n }\n\n const results = []\n let successCount = 0\n\n for (const filePath of filePaths) {\n try {\n // SEC-PATH-1:每个 path 都校验在 cwd 内,越界直接报错\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n results.push({ path: filePath, success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n continue;\n }\n\n // 检查文件状态:未跟踪 ??、已暂存 A/M/D、已修改(空状态会返回空字符串)\n const { stdout: statusOutput } = await execGitCommand([\u0027status\u0027, \u0027--porcelain\u0027, \u0027--\u0027, safeFilePath])\n\n // 未跟踪的文件 (??) → 直接删除\n if (statusOutput.startsWith(\u0027??\u0027)) {\n try {\n await fs.unlink(safeFilePath)\n results.push({ path: filePath, success: true, message: \u0027未跟踪的文件已删除\u0027 })\n successCount++\n continue\n } catch (err) {\n results.push({ path: filePath, success: false, error: `删除文件失败: ${err?.message || err}` })\n continue\n }\n }\n\n // 已暂存的文件,先取消暂存(不影响工作区)\n if (statusOutput.startsWith(\u0027A \u0027) || statusOutput.startsWith(\u0027M \u0027) || statusOutput.startsWith(\u0027D \u0027)) {\n await execGitCommand([\u0027reset\u0027, \u0027HEAD\u0027, \u0027--\u0027, safeFilePath])\n }\n\n // 已修改文件:丢弃工作区修改\n if (statusOutput) {\n await execGitCommand([\u0027checkout\u0027, \u0027--\u0027, safeFilePath])\n results.push({ path: filePath, success: true, message: \u0027文件修改已撤回\u0027 })\n successCount++\n } else {\n // 文件已无修改(可能在并发中被处理掉了)\n results.push({ path: filePath, success: true, message: \u0027文件无修改\u0027 })\n successCount++\n }\n } catch (err) {\n results.push({ path: filePath, success: false, error: err?.message || String(err) })\n }\n }\n\n res.json({\n success: true,\n count: filePaths.length,\n successCount,\n results\n })\n }));\n}\n","structuredPatch":[{"oldStart":13,"oldLines":7,"newStart":13,"newLines":7,"lines":[" // limitations under the License."," //"," import fs from \u0027fs/promises\u0027;","-import logger from \u0027../utils/logger.js\u0027","+import logger from \u0027../../utils/logger.js\u0027"," import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;"," "," import { createDiffHelpers } from \u0027./diffUtils.js\u0027;"]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c446ad7e21b86f5f27","duration_ms":5,"timestamp":"2026-06-27 09:49:17"} {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\diffUtils.js","old_string":"import logger from \u0027../utils/logger.js\u0027","new_string":"import logger from \u0027../../utils/logger.js\u0027","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\diffUtils.js","oldString":"import logger from \u0027../utils/logger.js\u0027","newString":"import logger from \u0027../../utils/logger.js\u0027","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\nimport logger from \u0027../utils/logger.js\u0027\n\nexport function createDiffHelpers({ execGitCommand }) {\n /**\n * 检查文件是否应该跳过diff显示(参考GitLab策略)\n * @param {string} filePath - 文件路径\n * @param {string} diffCommand - 要执行的git diff命令\n * @returns {Promise\u003c{shouldSkip: boolean, reason?: string, stats?: object}\u003e}\n */\n async function checkShouldSkipDiff(filePath, diffCommand) {\n // 1. 检查文件扩展名 - 编译/压缩/二进制文件\n const skipExtensions = /\\.(min\\.js|umd\\.cjs|bundle\\.js|dist\\.js|prod\\.js|map|wasm|exe|dll|so|dylib|bin|zip|tar|gz|rar|7z|jar|war|ear|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|bmp|ico|mp3|mp4|avi|mov|wmv|flv|webm|mkv|ttf|woff|woff2|eot|otf)$/i;\n if (skipExtensions.test(filePath)) {\n return {\n shouldSkip: true,\n reason: \u0027⚠️ 检测到编译/打包/二进制文件,diff已跳过显示。\\n\\n提示:这类文件通常是自动生成的或二进制文件,不适合查看diff。\\n如需查看,请使用命令行。\u0027\n };\n }\n\n // 2. 使用 --numstat 快速检查变更量(不获取实际内容,速度快)\n try {\n // 把传入的 diffCommand 字符串改写成 git diff/show 加 --numstat 的 argv 数组。\n // 不再走 shell 模式,避免 Windows cmd.exe 下引号兼容问题(参 utils execGitCommand execFile 化)。\n const numstatCommand = diffCommand.replace(/git (diff|show)/, \u0027git $1 --numstat\u0027)\n const argvMatch = numstatCommand.trim().match(/^git\\s+(\\S+)\\s*(.*)$/)\n let numstatArgs\n if (argvMatch) {\n const subCmd = argvMatch[1]\n const rest = argvMatch[2]\n // 简单按空白拆分,带引号段整体保留 -- \"--\" + 路径\n // 这里最常见形态是 \u0027git diff/show -- \"--path\"\u0027 或 \u0027git diff/show hash -- \"--path\"\u0027\n const tokens = []\n const re = /\"([^\"]*)\"|(\\S+)/g\n let m\n while ((m = re.exec(rest)) !== null) tokens.push(m[1] !== undefined ? m[1] : m[2])\n // 找到 \u0027--numstat\u0027 位置,把它加到 subCmd 后面作为第一个参数\n numstatArgs = [subCmd, \u0027--numstat\u0027, ...tokens]\n } else {\n // fallback: 走旧逻辑(字符串)\n numstatArgs = numstatCommand\n }\n const { stdout: numstat } = await execGitCommand(numstatArgs, { log: false });\n\n if (numstat.trim()) {\n const lines = numstat.trim().split(\u0027\\n\u0027);\n for (const line of lines) {\n const parts = line.split(\u0027\\t\u0027);\n if (parts.length \u003e= 3) {\n const added = parts[0];\n const deleted = parts[1];\n\n // 检查是否是二进制文件(显示为 - -)\n if (added === \u0027-\u0027 \u0026\u0026 deleted === \u0027-\u0027) {\n return {\n shouldSkip: true,\n reason: \u0027⚠️ 检测到二进制文件,diff已跳过显示。\\n\\n提示:二进制文件无法以文本形式显示diff。\u0027\n };\n }\n\n // 检查变更行数是否过多(超过3000行)\n const totalChanges = parseInt(added) + parseInt(deleted);\n if (!isNaN(totalChanges) \u0026\u0026 totalChanges \u003e 3000) {\n return {\n shouldSkip: true,\n reason: `⚠️ 变更内容过大 (${totalChanges.toLocaleString()} 行变更),diff已跳过显示以避免浏览器卡顿。\\n\\n提示:建议使用命令行或专业diff工具查看大文件变更。\\n增加:${parseInt(added).toLocaleString()} 行\\n删除:${parseInt(deleted).toLocaleString()} 行`,\n stats: { added: parseInt(added), deleted: parseInt(deleted), total: totalChanges }\n };\n }\n }\n }\n }\n } catch (error) {\n // numstat失败不影响后续流程\n logger.info(\u0027numstat检查失败,继续执行:\u0027, error.message);\n }\n\n // 3. 通过了初步检查\n return { shouldSkip: false };\n }\n\n /**\n * 检查diff内容大小,如果过大则跳过\n * @param {string} diffContent - diff内容\n * @param {number} maxSizeKB - 最大大小(KB),默认500KB\n * @returns {object|null} - 如果需要跳过返回提示对象,否则返回null\n */\n function checkDiffSize(diffContent, maxSizeKB = 500) {\n const diffSizeKB = Buffer.byteLength(diffContent, \u0027utf8\u0027) / 1024;\n if (diffSizeKB \u003e maxSizeKB) {\n return {\n diff: `⚠️ Diff内容过大 (${diffSizeKB.toFixed(1)} KB),已跳过显示以避免浏览器卡顿。\\n\\n提示:建议使用命令行查看大文件diff。`,\n isLargeFile: true,\n size: diffSizeKB\n };\n }\n return null;\n }\n\n /**\n * 从 diff 内容中统计增加和删除行数\n * @param {string} diffContent - diff内容\n * @returns {object} - {added, deleted}\n */\n function getDiffStats(diffContent) {\n if (!diffContent) return { added: 0, deleted: 0 };\n\n const lines = diffContent.split(\u0027\\n\u0027);\n let added = 0;\n let deleted = 0;\n\n for (const line of lines) {\n // 跳过diff头部信息\n if (line.startsWith(\u0027diff \u0027) || line.startsWith(\u0027index \u0027) ||\n line.startsWith(\u0027--- \u0027) || line.startsWith(\u0027+++ \u0027) ||\n line.startsWith(\u0027@@ \u0027)) {\n continue;\n }\n\n // 统计增加和删除的行\n if (line.startsWith(\u0027+\u0027)) {\n added++;\n } else if (line.startsWith(\u0027-\u0027)) {\n deleted++;\n }\n }\n\n return { added, deleted };\n }\n\n return {\n checkShouldSkipDiff,\n checkDiffSize,\n getDiffStats\n };\n}\n","structuredPatch":[{"oldStart":12,"oldLines":7,"newStart":12,"newLines":7,"lines":[" // See the License for the specific language governing permissions and"," // limitations under the License."," //","-import logger from \u0027../utils/logger.js\u0027","+import logger from \u0027../../utils/logger.js\u0027"," "," export function createDiffHelpers({ execGitCommand }) {"," /**"]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c446ad7e21b86f5f36","duration_ms":4,"timestamp":"2026-06-27 09:49:17"} {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\stash.js","old_string":"import logger from \u0027../utils/logger.js\u0027","new_string":"import logger from \u0027../../utils/logger.js\u0027","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\stash.js","oldString":"import logger from \u0027../utils/logger.js\u0027","newString":"import logger from \u0027../../utils/logger.js\u0027","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\nimport { createDiffHelpers } from \u0027./diffUtils.js\u0027;\nimport logger from \u0027../utils/logger.js\u0027\nimport { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;\n\nexport function registerGitStashRoutes({ app, execGitCommand, configManager }) {\n const { checkShouldSkipDiff, checkDiffSize, getDiffStats } = createDiffHelpers({ execGitCommand });\n\n // 获取stash列表\n app.get(\u0027/api/stash-list\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stdout } = await execGitCommand([\u0027stash\u0027, \u0027list\u0027]);\n \n // 解析stash列表\n const stashList = stdout.split(\u0027\\n\u0027)\n .filter(Boolean)\n .map(line =\u003e {\n // 尝试解析stash行,格式类似: stash@{0}: WIP on branch: commit message\n const match = line.match(/^(stash@\\{\\d+\\}): (.+)$/);\n if (match) {\n return {\n id: match[1],\n description: match[2]\n };\n }\n return null;\n })\n .filter(item =\u003e item !== null);\n \n res.json({ success: true, stashes: stashList });\n } catch (error) {\n logger.error(\u0027获取stash列表失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 创建新的stash\n app.post(\u0027/api/stash-save\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { message, includeUntracked, excludeLocked } = req.body;\n \n if (excludeLocked) {\n const lockedFiles = await configManager.getLockedFiles();\n // 包含未跟踪文件,确保状态与 UI 一致\n const { stdout: statusStdout } = await execGitCommand([\u0027status\u0027, \u0027--porcelain\u0027, \u0027--untracked-files=all\u0027], { log: false });\n const changedFiles = statusStdout\n .split(\u0027\\n\u0027)\n .filter(line =\u003e line.trim())\n .map(line =\u003e {\n const match = line.match(/^(..)\\s+(.+)$/);\n if (match) {\n const status = match[1];\n let filename = match[2];\n if (filename.startsWith(\u0027\"\u0027) \u0026\u0026 filename.endsWith(\u0027\"\u0027)) {\n filename = filename.slice(1, -1).replace(/\\\\(.)/g, \u0027$1\u0027);\n }\n return { status, filename };\n }\n return null;\n })\n .filter(Boolean);\n \n const path = (await import(\u0027path\u0027)).default;\n const fs = (await import(\u0027fs\u0027)).default;\n \n // 过滤出未锁定且需要包含在 stash 中的路径\n // 修复:当 includeUntracked === true 且变更项是“新目录”时,不能直接把目录作为 pathspec\n // 否则会把目录里的“锁定文件”一起打入 stash。这里将目录展开为具体文件,并逐个过滤锁定路径。\n const filesToStashSet = new Set();\n for (const item of changedFiles) {\n const { status, filename } = item;\n const normalizedFile = path.normalize(filename);\n \n // 检查是否被锁定\n const isLocked = lockedFiles.some(locked =\u003e {\n const normalizedLocked = path.normalize(locked);\n return normalizedFile === normalizedLocked || normalizedFile.startsWith(normalizedLocked + path.sep);\n });\n \n if (!isLocked) {\n try {\n const fullPath = path.resolve(filename);\n const stats = fs.statSync(fullPath);\n // 1) 已存在的普通文件:直接加入\n if (stats.isFile()) {\n filesToStashSet.add(filename);\n } else if (stats.isDirectory()) {\n // 2) 目录:当勾选了 includeUntracked 时,展开目录下的文件(包含未跟踪和已跟踪修改)\n if (includeUntracked) {\n try {\n // 使用 git 列出该目录下的未跟踪和已修改文件\n const { stdout: listStdout } = await execGitCommand([\u0027ls-files\u0027, \u0027-mo\u0027, \u0027--exclude-standard\u0027, \u0027--\u0027, filename], { log: false });\n const listed = listStdout\n .split(\u0027\\n\u0027)\n .map(l =\u003e l.trim())\n .filter(Boolean)\n // 仅保留该目录下的条目\n .filter(p =\u003e {\n const n = path.normalize(p);\n const base = path.normalize(filename);\n return n === base || n.startsWith(base + path.sep);\n });\n for (const p of listed) {\n const n = path.normalize(p);\n const locked = lockedFiles.some(locked =\u003e {\n const nl = path.normalize(locked);\n return n === nl || n.startsWith(nl + path.sep);\n });\n if (!locked) {\n filesToStashSet.add(p);\n }\n }\n } catch (_) {\n // 如果 git 列举失败,退化为不处理该目录\n }\n }\n }\n } catch (error) {\n // 3) 文件系统不可达的情况\n // 对于已删除的文件(D状态),我们仍然需要包含它们\n if (status.includes(\u0027D\u0027)) {\n filesToStashSet.add(filename);\n }\n // 其他情况(如路径不存在且不是删除状态)则跳过\n }\n }\n }\n \n let filesToStash = Array.from(filesToStashSet);\n if (filesToStash.length === 0) {\n return res.json({ success: false, message: \u0027所有更改都是锁定文件,无需储藏\u0027 });\n }\n \n // 在执行 stash 前进行候选校验:\n // 1) 仍有跟踪差异的文件\n try {\n const { stdout: diffNames } = await execGitCommand([\u0027diff\u0027, \u0027--name-only\u0027, \u0027--\u0027, ...filesToStash], { log: false });\n const trackedChanged = new Set(diffNames.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean));\n \n // 2) 仍为未跟踪的文件(当 includeUntracked 才检查)\n let untrackedExisting = new Set();\n if (includeUntracked) {\n const { stdout: others } = await execGitCommand([\u0027ls-files\u0027, \u0027--others\u0027, \u0027--exclude-standard\u0027, \u0027--\u0027, ...filesToStash], { log: false });\n untrackedExisting = new Set(others.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean));\n }\n \n // 合并有效集合\n const validSet = new Set();\n for (const f of filesToStash) {\n if (trackedChanged.has(f) || untrackedExisting.has(f)) {\n validSet.add(f);\n }\n }\n \n filesToStash = Array.from(validSet);\n } catch (e) {\n // 校验失败不应中断主流程,保守继续使用原集合\n logger.warn(\u0027候选文件有效性校验失败(将继续尝试储藏):\u0027, e?.message || e);\n }\n \n if (filesToStash.length === 0) {\n return res.json({ success: false, message: \u0027没有可储藏的更改(可能刚刚已储藏,或被锁定过滤)\u0027 });\n }\n \n const stashArgs = [\u0027stash\u0027, \u0027push\u0027];\n if (message) stashArgs.push(\u0027-m\u0027, message);\n if (includeUntracked) stashArgs.push(\u0027--include-untracked\u0027);\n if (filesToStash.length \u003e 0) stashArgs.push(\u0027--\u0027, ...filesToStash);\n \n const { stdout } = await execGitCommand(stashArgs);\n if (stdout.includes(\u0027No local changes to save\u0027)) {\n return res.json({ success: false, message: \u0027没有本地更改需要保存\u0027 });\n }\n return res.json({ success: true, message: \u0027成功保存未锁定的工作区更改\u0027, output: stdout });\n }\n \n const stashArgs = [\u0027stash\u0027, \u0027push\u0027];\n if (message) stashArgs.push(\u0027-m\u0027, message);\n if (includeUntracked) stashArgs.push(\u0027--include-untracked\u0027);\n const { stdout } = await execGitCommand(stashArgs);\n if (stdout.includes(\u0027No local changes to save\u0027)) {\n return res.json({ success: false, message: \u0027没有本地更改需要保存\u0027 });\n }\n res.json({ success: true, message: \u0027成功保存工作区更改\u0027, output: stdout });\n } catch (error) {\n // 友好处理:当 Git 返回 \"No valid patches in input\" 时,提示无可储藏更改\n const msg = error?.message || \u0027\u0027;\n if (msg.includes(\u0027No valid patches in input\u0027)) {\n return res.json({ success: false, message: \u0027没有可储藏的更改(可能刚刚已储藏,或被锁定过滤)\u0027 });\n }\n logger.error(\u0027保存stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 保存部分文件的stash\n app.post(\u0027/api/stash-save-partial\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { files, message, includeUntracked } = req.body;\n \n if (!files || !Array.isArray(files) || files.length === 0) {\n return res.json({ success: false, message: \u0027请选择要储藏的文件\u0027 });\n }\n \n // 构建 git stash push 命令\n const stashArgs = [\u0027stash\u0027, \u0027push\u0027];\n if (message) {\n stashArgs.push(\u0027-m\u0027, message);\n }\n if (includeUntracked) {\n stashArgs.push(\u0027--include-untracked\u0027);\n }\n \n // 添加文件列表\n stashArgs.push(\u0027--\u0027, ...files);\n \n const { stdout } = await execGitCommand(stashArgs);\n \n if (stdout.includes(\u0027No local changes to save\u0027)) {\n return res.json({ success: false, message: \u0027没有本地更改需要保存\u0027 });\n }\n \n res.json({\n success: true,\n message: `成功储藏 ${files.length} 个文件`,\n output: stdout\n });\n } catch (error) {\n const msg = error?.message || \u0027\u0027;\n if (msg.includes(\u0027No valid patches in input\u0027)) {\n return res.json({ success: false, message: \u0027没有可储藏的更改\u0027 });\n }\n logger.error(\u0027保存部分stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 应用特定的stash\n app.post(\u0027/api/stash-apply\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId, pop } = req.body;\n \n if (!stashId) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少stash ID参数\u0027\n });\n }\n \n // 决定是使用apply(保留stash)还是pop(应用后删除stash)\n const command = pop ? [\u0027stash\u0027, \u0027pop\u0027, stashId] : [\u0027stash\u0027, \u0027apply\u0027, stashId];\n \n try {\n const { stdout } = await execGitCommand(command);\n \n res.json({\n success: true,\n message: `成功${pop ? \u0027应用并删除\u0027 : \u0027应用\u0027}stash`,\n output: stdout\n });\n } catch (error) {\n // 检查是否有合并冲突\n if (error.message \u0026\u0026 error.message.includes(\u0027CONFLICT\u0027)) {\n return res.status(409).json({\n success: false,\n hasConflicts: true,\n error: \u0027应用stash时发生冲突,需要手动解决\u0027,\n details: error.message\n });\n }\n throw error;\n }\n } catch (error) {\n logger.error(\u0027应用stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 删除特定的stash\n app.post(\u0027/api/stash-drop\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId } = req.body;\n \n if (!stashId) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少stash ID参数\u0027\n });\n }\n \n const { stdout } = await execGitCommand([\u0027stash\u0027, \u0027drop\u0027, stashId]);\n \n res.json({\n success: true,\n message: \u0027成功删除stash\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027删除stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 清空所有stash\n app.post(\u0027/api/stash-clear\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stdout } = await execGitCommand([\u0027stash\u0027, \u0027clear\u0027]);\n \n res.json({\n success: true,\n message: \u0027成功清空所有stash\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027清空stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 获取stash中的文件列表(包含未跟踪文件)\n app.get(\u0027/api/stash-files\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId } = req.query;\n \n if (!stashId) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少stash ID参数\u0027\n });\n }\n \n logger.info(`获取stash文件列表: stashId=${stashId}`);\n \n // 0) 解析出当前 stash 提交及其父提交哈希,避免在 Windows 上使用 ^ 语法\n const { stdout: parentsLine } = await execGitCommand([\u0027rev-list\u0027, \u0027--parents\u0027, \u0027-n\u0027, \u00271\u0027, stashId], { log: false });\n const hashes = parentsLine.trim().split(/\\s+/).filter(Boolean);\n const stashCommit = hashes[0] || \u0027\u0027;\n const parent1 = hashes[1] || \u0027\u0027;\n const parent3 = hashes[3] || \u0027\u0027; // 当包含未跟踪文件时,第三父才存在\n \n // 1) 跟踪文件的变更列表:父1 与 stash 提交的差异(若无父1则为空)\n let trackedFiles = [];\n if (parent1) {\n const { stdout: trackedOut } = await execGitCommand([\u0027diff\u0027, \u0027--name-only\u0027, parent1, stashCommit], { log: false });\n trackedFiles = trackedOut.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean);\n }\n \n // 2) 未跟踪文件:来自第三父(若存在)\n let untrackedFiles = [];\n if (parent3) {\n const { stdout: untrackedOut } = await execGitCommand([\u0027ls-tree\u0027, \u0027-r\u0027, \u0027--name-only\u0027, parent3], { log: false });\n untrackedFiles = untrackedOut.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean);\n }\n \n // 合并并去重\n const fileSet = new Set([ ...trackedFiles, ...untrackedFiles ]);\n const files = Array.from(fileSet);\n logger.info(`找到${files.length}个stash文件(含未跟踪):`, files);\n \n res.json({\n success: true,\n files\n });\n } catch (error) {\n logger.error(\u0027获取stash文件列表失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `获取stash文件列表失败: ${error.message}`\n });\n }\n }));\n\n // 获取stash中特定文件的差异(包含未跟踪文件)\n app.get(\u0027/api/stash-file-diff\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId, file } = req.query;\n \n if (!stashId || !file) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少必要参数\u0027\n });\n }\n \n logger.info(`获取stash文件差异: stashId=${stashId}, file=${file}`);\n \n // 先解析父提交哈希,避免使用 ^ 语法\n const { stdout: parentsLine } = await execGitCommand([\u0027rev-list\u0027, \u0027--parents\u0027, \u0027-n\u0027, \u00271\u0027, stashId], { log: false });\n const hashes = parentsLine.trim().split(/\\s+/).filter(Boolean);\n const stashCommit = hashes[0] || \u0027\u0027;\n const parent1 = hashes[1] || \u0027\u0027;\n const parent3 = hashes[3] || \u0027\u0027;\n \n // 检查该文件是否来自第三父(未跟踪文件)\n let isFromThirdParent = false;\n if (parent3) {\n try {\n await execGitCommand([\u0027cat-file\u0027, \u0027-e\u0027, `${parent3}:${file}`], { log: false });\n isFromThirdParent = true;\n } catch (_) {\n isFromThirdParent = false;\n }\n }\n \n if (isFromThirdParent) {\n // 未跟踪文件:读取第三父中的内容,构造新增文件的统一diff\n const { stdout: blob } = await execGitCommand([\u0027show\u0027, `${parent3}:${file}`], { log: false });\n \n // 检查文件大小\n const sizeCheck = checkDiffSize(blob, 500);\n if (sizeCheck) {\n return res.json({ success: true, ...sizeCheck });\n }\n \n const lines = blob.endsWith(\u0027\\n\u0027) ? blob.slice(0, -1).split(\u0027\\n\u0027) : blob.split(\u0027\\n\u0027);\n const lineCount = lines.length;\n \n // 检查行数\n if (lineCount \u003e 10000) {\n return res.json({\n success: true,\n diff: `⚠️ 变更内容过大 (${lineCount.toLocaleString()} 行),diff已跳过显示以避免浏览器卡顿。\\n\\n提示:建议使用命令行查看大文件变更。`,\n isLargeFile: true,\n stats: { added: lineCount, deleted: 0, total: lineCount }\n });\n }\n \n const plusLines = lines.map(l =\u003e `+${l}`).join(\u0027\\n\u0027);\n const diffText = [\n `diff --git a/${file} b/${file}`,\n `new file mode 100644`,\n `--- /dev/null`,\n `+++ b/${file}`,\n `@@ -0,0 +${lineCount} @@`,\n `${plusLines}`\n ].join(\u0027\\n\u0027);\n \n return res.json({ success: true, diff: diffText });\n }\n \n // 否则,使用原有方式获取与父1的变更\n // checkShouldSkipDiff 接受字符串命令用于日志展示,这里只用于大小判断;\n // 走 execGitCommand 时用 argv 数组,避免 Windows 下 cmd.exe 拼引号被破坏。\n const diffCommandForCheck = `git show ${stashCommit} -- \"${file}\"`;\n const diffCommandArgs = [\u0027show\u0027, stashCommit, \u0027--\u0027, file];\n \n // 使用优化的检查函数\n const skipCheck = await checkShouldSkipDiff(file, diffCommandForCheck);\n if (skipCheck.shouldSkip) {\n return res.json({\n success: true,\n diff: skipCheck.reason,\n isLargeFile: true,\n stats: skipCheck.stats\n });\n }\n \n const { stdout } = await execGitCommand(diffCommandArgs);\n \n logger.info(`获取到差异内容,长度: ${stdout.length}`);\n \n // 检查实际diff大小\n const sizeCheck = checkDiffSize(stdout, 500);\n if (sizeCheck) {\n return res.json({ success: true, ...sizeCheck });\n }\n \n // 统计增加和删除行数\n const stats = getDiffStats(stdout);\n \n res.json({ success: true, diff: stdout, stats });\n } catch (error) {\n logger.error(\u0027获取stash文件差异失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `获取stash文件差异失败: ${error.message}`\n });\n }\n }));\n\n // 获取stash中特定文件的完整内容对比(原始 vs 储藏后)\n app.get(\u0027/api/stash-file-compare\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId, file } = req.query;\n \n if (!stashId || !file) {\n throw new HttpError(400, \u0027缺少必要参数\u0027);\n }\n \n // 解析父提交哈希\n const { stdout: parentsLine } = await execGitCommand([\u0027rev-list\u0027, \u0027--parents\u0027, \u0027-n\u0027, \u00271\u0027, stashId], { log: false });\n const hashes = parentsLine.trim().split(/\\s+/).filter(Boolean);\n const stashCommit = hashes[0] || \u0027\u0027;\n const parent1 = hashes[1] || \u0027\u0027;\n const parent3 = hashes[3] || \u0027\u0027;\n \n // 检查是否为未跟踪文件(来自第三父)\n let isFromThirdParent = false;\n if (parent3) {\n try {\n await execGitCommand([\u0027cat-file\u0027, \u0027-e\u0027, `${parent3}:${file}`], { log: false });\n isFromThirdParent = true;\n } catch (_) {\n isFromThirdParent = false;\n }\n }\n \n // 获取原始内容(储藏前,来自 parent1)\n let original = \u0027\u0027;\n if (!isFromThirdParent \u0026\u0026 parent1) {\n try {\n const { stdout: origOut } = await execGitCommand([\u0027show\u0027, `${parent1}:${file}`], { log: false });\n original = origOut ?? \u0027\u0027;\n } catch (_) {\n original = \u0027\u0027; // 文件在储藏前不存在\n }\n }\n \n // 获取储藏后的内容\n let modified = \u0027\u0027;\n const modRef = isFromThirdParent ? parent3 : stashCommit;\n if (modRef) {\n try {\n const { stdout: modOut } = await execGitCommand([\u0027show\u0027, `${modRef}:${file}`], { log: false });\n modified = modOut ?? \u0027\u0027;\n } catch (_) {\n modified = \u0027\u0027; // 文件已被删除\n }\n }\n \n res.json({ success: true, original, modified });\n } catch (error) {\n logger.error(\u0027获取stash文件对比失败:\u0027, error);\n res.status(500).json({ success: false, error: `获取stash文件对比失败: ${error.message}` });\n }\n }));\n}\n","structuredPatch":[{"oldStart":13,"oldLines":7,"newStart":13,"newLines":7,"lines":[" // limitations under the License."," //"," import { createDiffHelpers } from \u0027./diffUtils.js\u0027;","-import logger from \u0027../utils/logger.js\u0027","+import logger from \u0027../../utils/logger.js\u0027"," import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;"," "," export function registerGitStashRoutes({ app, execGitCommand, configManager }) {"]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c446ad7e21b86f5f4d","duration_ms":6,"timestamp":"2026-06-27 09:49:18"} {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\tags.js","old_string":"import logger from \u0027../utils/logger.js\u0027","new_string":"import logger from \u0027../../utils/logger.js\u0027","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\tags.js","oldString":"import logger from \u0027../utils/logger.js\u0027","newString":"import logger from \u0027../../utils/logger.js\u0027","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\nimport { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027\n\nimport logger from \u0027../utils/logger.js\u0027\n\nexport function registerGitTagRoutes({ app, execGitCommand, clearCommandHistory }) {\n // ============ Git Tag 相关接口 ============\n\n // 创建标签\n app.post(\u0027/api/create-tag\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { tagName, message, type, commit } = req.body;\n \n if (!tagName) {\n throw new HttpError(400, \u0027缺少标签名称\u0027);\n }\n \n const tagArgs = [\u0027tag\u0027];\n \n if (type === \u0027annotated\u0027) {\n // 附注标签\n if (!message) {\n throw new HttpError(400, \u0027附注标签需要提供说明信息\u0027);\n }\n tagArgs.push(\u0027-a\u0027, tagName, \u0027-m\u0027, message);\n } else {\n // 轻量标签\n tagArgs.push(tagName);\n }\n \n // 如果指定了commit,添加到命令中\n if (commit \u0026\u0026 commit.trim()) {\n tagArgs.push(commit.trim());\n }\n \n const { stdout } = await execGitCommand(tagArgs);\n \n res.json({\n success: true,\n message: \u0027标签创建成功\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027创建标签失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 获取标签列表\n app.get(\u0027/api/list-tags\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n // 使用 git tag -n --format 获取详细信息\n const { stdout } = await execGitCommand(\n [\u0027tag\u0027, \u0027-n\u0027, \u0027--format=%(refname:short)|%(objectname:short)|%(creatordate:iso8601)|%(subject)\u0027]\n );\n \n if (!stdout.trim()) {\n return res.json({ success: true, tags: [] });\n }\n \n const tags = stdout.trim().split(\u0027\\n\u0027).map(line =\u003e {\n const [name, commit, date, message] = line.split(\u0027|\u0027);\n return {\n name: name || \u0027\u0027,\n commit: commit || \u0027\u0027,\n date: date || \u0027\u0027,\n message: message || \u0027\u0027,\n type: \u0027lightweight\u0027 // 默认为轻量标签\n };\n });\n \n // 检测哪些是附注标签\n for (const tag of tags) {\n try {\n const { stdout: typeCheck } = await execGitCommand(\n [\u0027cat-file\u0027, \u0027-t\u0027, tag.name],\n { log: false }\n );\n if (typeCheck.trim() === \u0027tag\u0027) {\n tag.type = \u0027annotated\u0027;\n }\n } catch (error) {\n // 忽略错误,保持默认值\n }\n }\n \n res.json({ success: true, tags });\n } catch (error) {\n logger.error(\u0027获取标签列表失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 推送标签到远程\n app.post(\u0027/api/push-tag\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { tagName } = req.body;\n \n if (!tagName) {\n throw new HttpError(400, \u0027缺少标签名称\u0027);\n }\n \n const { stdout } = await execGitCommand([\u0027push\u0027, \u0027origin\u0027, tagName]);\n \n res.json({\n success: true,\n message: \u0027标签推送成功\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027推送标签失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 推送所有标签到远程\n app.post(\u0027/api/push-all-tags\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stdout } = await execGitCommand([\u0027push\u0027, \u0027origin\u0027, \u0027--tags\u0027]);\n \n res.json({\n success: true,\n message: \u0027所有标签推送成功\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027推送所有标签失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 删除标签\n app.post(\u0027/api/delete-tag\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { tagName } = req.body;\n \n if (!tagName) {\n throw new HttpError(400, \u0027缺少标签名称\u0027);\n }\n \n const { stdout } = await execGitCommand([\u0027tag\u0027, \u0027-d\u0027, tagName]);\n \n res.json({\n success: true,\n message: \u0027标签删除成功\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027删除标签失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 添加命令历史的清空API\n app.post(\u0027/api/clear-command-history\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const result = clearCommandHistory();\n res.json({ success: result });\n } catch (error) {\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n}\n","structuredPatch":[{"oldStart":14,"oldLines":7,"newStart":14,"newLines":7,"lines":[" //"," import { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027"," ","-import logger from \u0027../utils/logger.js\u0027","+import logger from \u0027../../utils/logger.js\u0027"," "," export function registerGitTagRoutes({ app, execGitCommand, clearCommandHistory }) {"," // ============ Git Tag 相关接口 ============"]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c446ad7e21b86f5f56","duration_ms":4,"timestamp":"2026-06-27 09:49:18"} {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\diff.js","old_string":"import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;","new_string":"import { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027;","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\diff.js","oldString":"import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;","newString":"import { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027;","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\nimport fs from \u0027fs/promises\u0027;\nimport logger from \u0027../../utils/logger.js\u0027\nimport { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;\n\nimport { createDiffHelpers } from \u0027./diffUtils.js\u0027;\n\nimport { ensureWithinCwd } from \u0027../../utils/pathGuard.js\u0027;\n\n/**\n * SEC-PATH-1: resolve user path to be inside process.cwd(), returns null on escape\n * @param {string} userPath\n * @returns {Promise\u003cstring|null\u003e}\n */\nasync function safePathInProject(userPath) {\n if (typeof userPath !== \u0027string\u0027 || !userPath) return null;\n if (/[\\x00-\\x1f]/.test(userPath)) return null;\n const cwd = process.cwd();\n const result = await ensureWithinCwd(userPath, cwd);\n return result ? result.safePath : null;\n}\n\nexport function registerGitDiffRoutes({\n app,\n execGitCommand\n}) {\n const { checkShouldSkipDiff, checkDiffSize, getDiffStats } = createDiffHelpers({ execGitCommand });\n\n const skipExtensions = /\\.(min\\.js|umd\\.cjs|bundle\\.js|dist\\.js|prod\\.js|map|wasm|exe|dll|so|dylib|bin|zip|tar|gz|rar|7z|jar|war|ear|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|png|gif|bmp|ico|mp3|mp4|avi|mov|wmv|flv|webm|mkv|ttf|woff|woff2|eot|otf)$/i;\n const maxBytes = 1024 * 1024;\n\n // 获取文件差异\n app.get(\u0027/api/diff\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n \n if (!filePath) {\n return res.status(400).json({ error: \u0027缺少文件路径参数\u0027 });\n }\n \n const diffArgs = [\u0027diff\u0027, \u0027--\u0027, filePath];\n \n // 使用优化的检查函数(diffCommand 用于内部 numstat 检测的字符串转换,\n // 实际走 git 时用 argv 数组,避免 Windows cmd.exe 拼引号问题)\n const skipCheck = await checkShouldSkipDiff(filePath, `git diff -- \"${filePath}\"`);\n if (skipCheck.shouldSkip) {\n return res.json({\n diff: skipCheck.reason,\n isLargeFile: true,\n stats: skipCheck.stats\n });\n }\n \n // 执行git diff命令获取文件差异\n const { stdout } = await execGitCommand(diffArgs);\n \n // 检查实际diff大小\n const sizeCheck = checkDiffSize(stdout, 500);\n if (sizeCheck) {\n return res.json(sizeCheck);\n }\n \n // 统计增加和删除行数\n const stats = getDiffStats(stdout);\n \n res.json({ diff: stdout, stats });\n } catch (error) {\n res.status(500).json({ error: error.message });\n }\n }));\n // 获取已暂存文件差异\n app.get(\u0027/api/diff-cached\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n \n if (!filePath) {\n return res.status(400).json({ error: \u0027缺少文件路径参数\u0027 });\n }\n \n const diffArgs = [\u0027diff\u0027, \u0027--cached\u0027, \u0027--\u0027, filePath];\n \n // 使用优化的检查函数\n const skipCheck = await checkShouldSkipDiff(filePath, `git diff --cached -- \"${filePath}\"`);\n if (skipCheck.shouldSkip) {\n return res.json({\n diff: skipCheck.reason,\n isLargeFile: true,\n stats: skipCheck.stats\n });\n }\n \n // 执行git diff --cached命令获取已暂存文件差异\n const { stdout } = await execGitCommand(diffArgs);\n \n // 检查实际diff大小\n const sizeCheck = checkDiffSize(stdout, 500);\n if (sizeCheck) {\n return res.json(sizeCheck);\n }\n \n // 统计增加和删除行数\n const stats = getDiffStats(stdout);\n \n res.json({ diff: stdout, stats });\n } catch (error) {\n res.status(500).json({ error: error.message });\n }\n }));\n\n // 获取全量 diff(git diff HEAD,含已暂存与未暂存的所有变更)\n app.get(\u0027/api/diff-head\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stdout } = await execGitCommand([\u0027diff\u0027, \u0027HEAD\u0027]);\n const MAX = 500 * 1024;\n const content = stdout.length \u003e MAX\n ? stdout.slice(0, MAX) + \u0027\\n\\n[内容过大,已截断]\u0027\n : stdout;\n res.json({ success: true, diff: content });\n } catch (error) {\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 获取文件内容 (用于未跟踪文件)\n app.get(\u0027/api/file-content\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n\n if (!filePath) {\n return res.status(400).json({ error: \u0027缺少文件路径参数\u0027 });\n }\n\n // SEC-PATH-1:resolve 到 cwd 内,越界返回 403\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n try {\n // 二进制/产物文件:直接告知前端 isBinary,不读取内容\n if (skipExtensions.test(safeFilePath)) {\n const isImage = /\\.(png|jpg|jpeg|gif|webp|bmp|ico|svg)$/i.test(safeFilePath);\n return res.json({\n success: true,\n isBinary: true,\n isImage,\n content: isImage\n ? \u0027⚠️ 该文件是图片,建议在预览中查看。\u0027\n : \u0027⚠️ 检测到二进制/编译产物文件,不支持以文本形式显示完整内容。\u0027\n });\n }\n\n // 读取文件内容(走 safePath,不是 user input)\n const content = await fs.readFile(safeFilePath, \u0027utf8\u0027);\n res.json({ success: true, content });\n } catch (readError) {\n res.status(500).json({ success: false, error: `无法读取文件: ${readError.message}` });\n }\n } catch (error) {\n res.status(500).json({ error: error.message });\n }\n }));\n\n app.get(\u0027/api/git-file-content\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const filePath = req.query.file;\n const rev = req.query.rev;\n\n if (!filePath || !rev) {\n throw new HttpError(400, \u0027缺少必要参数\u0027);\n }\n\n // SEC-PATH-1:路径必须在 cwd 内,越界返回 403\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n if (skipExtensions.test(safeFilePath)) {\n return res.json({\n success: true,\n isBinary: true,\n content: \u0027⚠️ 检测到二进制/编译产物文件,不支持以文本形式显示完整内容。\u0027\n });\n }\n\n const r = String(rev);\n // 用 safeFilePath 而不是 user input,防注入\n const spec = r === \u0027:\u0027 ? `:${safeFilePath}` : `${r}:${safeFilePath}`;\n\n let sizeBytes = 0;\n try {\n const { stdout: sizeOut } = await execGitCommand([\u0027cat-file\u0027, \u0027-s\u0027, spec], { log: false });\n sizeBytes = parseInt(String(sizeOut).trim(), 10) || 0;\n } catch (e) {\n return res.json({ success: true, notFound: true, content: \u0027\u0027 });\n }\n\n if (sizeBytes \u003e maxBytes) {\n return res.json({\n success: true,\n isLargeFile: true,\n size: sizeBytes,\n content: `⚠️ 文件内容过大 (${(sizeBytes / 1024).toFixed(1)} KB),已跳过显示以避免浏览器卡顿。`\n });\n }\n\n const { stdout } = await execGitCommand([\u0027show\u0027, spec], { log: false });\n return res.json({ success: true, content: stdout ?? \u0027\u0027 });\n } catch (error) {\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 解决冲突:保存解决后的文件内容\n app.post(\u0027/api/resolve-conflict\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { filePath, content } = req.body;\n\n if (!filePath) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少文件路径参数\u0027\n });\n }\n\n // SEC-PATH-1:resolve 到 cwd 内,越界 403\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n if (content === undefined) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少文件内容参数\u0027\n });\n }\n\n try {\n // 写入解决后的内容到文件(走 safePath)\n await fs.writeFile(safeFilePath, content, \u0027utf8\u0027);\n\n res.json({\n success: true,\n message: \u0027冲突已解决,文件已更新\u0027\n });\n } catch (writeError) {\n res.status(500).json({\n success: false,\n error: `保存文件失败: ${writeError.message}`\n });\n }\n } catch (error) {\n logger.error(\u0027解决冲突失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `解决冲突失败: ${error.message}`\n });\n }\n }));\n\n // 撤回文件修改\n app.post(\u0027/api/revert_file\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { filePath } = req.body;\n\n if (!filePath) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少文件路径参数\u0027\n });\n }\n\n // SEC-PATH-1:resolve 到 cwd 内\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n return res.status(403).json({ success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n }\n\n // 检查文件状态:未跟踪文件需要删除,修改文件需要恢复\n const { stdout: statusOutput } = await execGitCommand([\u0027status\u0027, \u0027--porcelain\u0027, \u0027--\u0027, safeFilePath]);\n\n // 未跟踪的文件 (??), 需要删除它\n if (statusOutput.startsWith(\u0027??\u0027)) {\n try {\n await fs.unlink(safeFilePath);\n return res.json({ success: true, message: \u0027未跟踪的文件已删除\u0027 });\n } catch (error) {\n return res.status(500).json({\n success: false,\n error: `删除文件失败: ${error.message}`\n });\n }\n }\n // 已暂存的文件,先取消暂存\n else if (statusOutput.startsWith(\u0027A \u0027) || statusOutput.startsWith(\u0027M \u0027) || statusOutput.startsWith(\u0027D \u0027)) {\n // 先取消暂存\n await execGitCommand([\u0027reset\u0027, \u0027HEAD\u0027, \u0027--\u0027, safeFilePath]);\n }\n\n // 已修改文件,取消所有本地修改\n if (statusOutput) {\n await execGitCommand([\u0027checkout\u0027, \u0027--\u0027, safeFilePath]);\n return res.json({ success: true, message: \u0027文件修改已撤回\u0027 });\n } else {\n return res.status(400).json({\n success: false,\n error: \u0027文件没有修改或不存在\u0027\n });\n }\n } catch (error) {\n logger.error(\u0027撤回文件修改失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `撤回文件修改失败: ${error.message}`\n });\n }\n }));\n\n // 批量撤回文件修改(未跟踪删除,已修改 checkout 还原)\n // body: { filePaths: string[] }\n // 返回: { success, count, results: [{ path, success, error? }] }\n app.post(\u0027/api/revert_files\u0027, asyncRoute(async (req, res) =\u003e {\n const filePaths = Array.isArray(req.body?.filePaths) ? req.body.filePaths : []\n if (filePaths.length === 0) {\n return res.status(400).json({ success: false, error: \u0027缺少文件路径参数\u0027 })\n }\n\n // 批量大小限制,防止 DoS\n const MAX_BATCH = 200;\n if (filePaths.length \u003e MAX_BATCH) {\n return res.status(400).json({ success: false, error: `批量大小不能超过 ${MAX_BATCH}` });\n }\n\n const results = []\n let successCount = 0\n\n for (const filePath of filePaths) {\n try {\n // SEC-PATH-1:每个 path 都校验在 cwd 内,越界直接报错\n const safeFilePath = await safePathInProject(String(filePath));\n if (!safeFilePath) {\n results.push({ path: filePath, success: false, error: \u0027禁止访问工作目录以外的文件\u0027 });\n continue;\n }\n\n // 检查文件状态:未跟踪 ??、已暂存 A/M/D、已修改(空状态会返回空字符串)\n const { stdout: statusOutput } = await execGitCommand([\u0027status\u0027, \u0027--porcelain\u0027, \u0027--\u0027, safeFilePath])\n\n // 未跟踪的文件 (??) → 直接删除\n if (statusOutput.startsWith(\u0027??\u0027)) {\n try {\n await fs.unlink(safeFilePath)\n results.push({ path: filePath, success: true, message: \u0027未跟踪的文件已删除\u0027 })\n successCount++\n continue\n } catch (err) {\n results.push({ path: filePath, success: false, error: `删除文件失败: ${err?.message || err}` })\n continue\n }\n }\n\n // 已暂存的文件,先取消暂存(不影响工作区)\n if (statusOutput.startsWith(\u0027A \u0027) || statusOutput.startsWith(\u0027M \u0027) || statusOutput.startsWith(\u0027D \u0027)) {\n await execGitCommand([\u0027reset\u0027, \u0027HEAD\u0027, \u0027--\u0027, safeFilePath])\n }\n\n // 已修改文件:丢弃工作区修改\n if (statusOutput) {\n await execGitCommand([\u0027checkout\u0027, \u0027--\u0027, safeFilePath])\n results.push({ path: filePath, success: true, message: \u0027文件修改已撤回\u0027 })\n successCount++\n } else {\n // 文件已无修改(可能在并发中被处理掉了)\n results.push({ path: filePath, success: true, message: \u0027文件无修改\u0027 })\n successCount++\n }\n } catch (err) {\n results.push({ path: filePath, success: false, error: err?.message || String(err) })\n }\n }\n\n res.json({\n success: true,\n count: filePaths.length,\n successCount,\n results\n })\n }));\n}\n","structuredPatch":[{"oldStart":14,"oldLines":7,"newStart":14,"newLines":7,"lines":[" //"," import fs from \u0027fs/promises\u0027;"," import logger from \u0027../../utils/logger.js\u0027","-import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;","+import { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027;"," "," import { createDiffHelpers } from \u0027./diffUtils.js\u0027;"," "]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c4c1bc7c6396cbac0e","duration_ms":11,"timestamp":"2026-06-27 09:49:47"} {"session_id":"dd687590-60c2-4696-83e4-ff1cb8a7773a","transcript_path":"C:\\Users\\xuze\\.claude\\projects\\E--workspace-github-workspace-zen-git\\dd687590-60c2-4696-83e4-ff1cb8a7773a.jsonl","cwd":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server","permission_mode":"bypassPermissions","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\stash.js","old_string":"import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;","new_string":"import { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027;","replace_all":false},"tool_response":{"filePath":"E:\\workspace\\github_workspace\\zen-git\\src\\ui\\server\\routes\\git\\stash.js","oldString":"import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;","newString":"import { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027;","originalFile":"// Copyright 2026 xz333221\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n//\nimport { createDiffHelpers } from \u0027./diffUtils.js\u0027;\nimport logger from \u0027../../utils/logger.js\u0027\nimport { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;\n\nexport function registerGitStashRoutes({ app, execGitCommand, configManager }) {\n const { checkShouldSkipDiff, checkDiffSize, getDiffStats } = createDiffHelpers({ execGitCommand });\n\n // 获取stash列表\n app.get(\u0027/api/stash-list\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stdout } = await execGitCommand([\u0027stash\u0027, \u0027list\u0027]);\n \n // 解析stash列表\n const stashList = stdout.split(\u0027\\n\u0027)\n .filter(Boolean)\n .map(line =\u003e {\n // 尝试解析stash行,格式类似: stash@{0}: WIP on branch: commit message\n const match = line.match(/^(stash@\\{\\d+\\}): (.+)$/);\n if (match) {\n return {\n id: match[1],\n description: match[2]\n };\n }\n return null;\n })\n .filter(item =\u003e item !== null);\n \n res.json({ success: true, stashes: stashList });\n } catch (error) {\n logger.error(\u0027获取stash列表失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 创建新的stash\n app.post(\u0027/api/stash-save\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { message, includeUntracked, excludeLocked } = req.body;\n \n if (excludeLocked) {\n const lockedFiles = await configManager.getLockedFiles();\n // 包含未跟踪文件,确保状态与 UI 一致\n const { stdout: statusStdout } = await execGitCommand([\u0027status\u0027, \u0027--porcelain\u0027, \u0027--untracked-files=all\u0027], { log: false });\n const changedFiles = statusStdout\n .split(\u0027\\n\u0027)\n .filter(line =\u003e line.trim())\n .map(line =\u003e {\n const match = line.match(/^(..)\\s+(.+)$/);\n if (match) {\n const status = match[1];\n let filename = match[2];\n if (filename.startsWith(\u0027\"\u0027) \u0026\u0026 filename.endsWith(\u0027\"\u0027)) {\n filename = filename.slice(1, -1).replace(/\\\\(.)/g, \u0027$1\u0027);\n }\n return { status, filename };\n }\n return null;\n })\n .filter(Boolean);\n \n const path = (await import(\u0027path\u0027)).default;\n const fs = (await import(\u0027fs\u0027)).default;\n \n // 过滤出未锁定且需要包含在 stash 中的路径\n // 修复:当 includeUntracked === true 且变更项是“新目录”时,不能直接把目录作为 pathspec\n // 否则会把目录里的“锁定文件”一起打入 stash。这里将目录展开为具体文件,并逐个过滤锁定路径。\n const filesToStashSet = new Set();\n for (const item of changedFiles) {\n const { status, filename } = item;\n const normalizedFile = path.normalize(filename);\n \n // 检查是否被锁定\n const isLocked = lockedFiles.some(locked =\u003e {\n const normalizedLocked = path.normalize(locked);\n return normalizedFile === normalizedLocked || normalizedFile.startsWith(normalizedLocked + path.sep);\n });\n \n if (!isLocked) {\n try {\n const fullPath = path.resolve(filename);\n const stats = fs.statSync(fullPath);\n // 1) 已存在的普通文件:直接加入\n if (stats.isFile()) {\n filesToStashSet.add(filename);\n } else if (stats.isDirectory()) {\n // 2) 目录:当勾选了 includeUntracked 时,展开目录下的文件(包含未跟踪和已跟踪修改)\n if (includeUntracked) {\n try {\n // 使用 git 列出该目录下的未跟踪和已修改文件\n const { stdout: listStdout } = await execGitCommand([\u0027ls-files\u0027, \u0027-mo\u0027, \u0027--exclude-standard\u0027, \u0027--\u0027, filename], { log: false });\n const listed = listStdout\n .split(\u0027\\n\u0027)\n .map(l =\u003e l.trim())\n .filter(Boolean)\n // 仅保留该目录下的条目\n .filter(p =\u003e {\n const n = path.normalize(p);\n const base = path.normalize(filename);\n return n === base || n.startsWith(base + path.sep);\n });\n for (const p of listed) {\n const n = path.normalize(p);\n const locked = lockedFiles.some(locked =\u003e {\n const nl = path.normalize(locked);\n return n === nl || n.startsWith(nl + path.sep);\n });\n if (!locked) {\n filesToStashSet.add(p);\n }\n }\n } catch (_) {\n // 如果 git 列举失败,退化为不处理该目录\n }\n }\n }\n } catch (error) {\n // 3) 文件系统不可达的情况\n // 对于已删除的文件(D状态),我们仍然需要包含它们\n if (status.includes(\u0027D\u0027)) {\n filesToStashSet.add(filename);\n }\n // 其他情况(如路径不存在且不是删除状态)则跳过\n }\n }\n }\n \n let filesToStash = Array.from(filesToStashSet);\n if (filesToStash.length === 0) {\n return res.json({ success: false, message: \u0027所有更改都是锁定文件,无需储藏\u0027 });\n }\n \n // 在执行 stash 前进行候选校验:\n // 1) 仍有跟踪差异的文件\n try {\n const { stdout: diffNames } = await execGitCommand([\u0027diff\u0027, \u0027--name-only\u0027, \u0027--\u0027, ...filesToStash], { log: false });\n const trackedChanged = new Set(diffNames.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean));\n \n // 2) 仍为未跟踪的文件(当 includeUntracked 才检查)\n let untrackedExisting = new Set();\n if (includeUntracked) {\n const { stdout: others } = await execGitCommand([\u0027ls-files\u0027, \u0027--others\u0027, \u0027--exclude-standard\u0027, \u0027--\u0027, ...filesToStash], { log: false });\n untrackedExisting = new Set(others.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean));\n }\n \n // 合并有效集合\n const validSet = new Set();\n for (const f of filesToStash) {\n if (trackedChanged.has(f) || untrackedExisting.has(f)) {\n validSet.add(f);\n }\n }\n \n filesToStash = Array.from(validSet);\n } catch (e) {\n // 校验失败不应中断主流程,保守继续使用原集合\n logger.warn(\u0027候选文件有效性校验失败(将继续尝试储藏):\u0027, e?.message || e);\n }\n \n if (filesToStash.length === 0) {\n return res.json({ success: false, message: \u0027没有可储藏的更改(可能刚刚已储藏,或被锁定过滤)\u0027 });\n }\n \n const stashArgs = [\u0027stash\u0027, \u0027push\u0027];\n if (message) stashArgs.push(\u0027-m\u0027, message);\n if (includeUntracked) stashArgs.push(\u0027--include-untracked\u0027);\n if (filesToStash.length \u003e 0) stashArgs.push(\u0027--\u0027, ...filesToStash);\n \n const { stdout } = await execGitCommand(stashArgs);\n if (stdout.includes(\u0027No local changes to save\u0027)) {\n return res.json({ success: false, message: \u0027没有本地更改需要保存\u0027 });\n }\n return res.json({ success: true, message: \u0027成功保存未锁定的工作区更改\u0027, output: stdout });\n }\n \n const stashArgs = [\u0027stash\u0027, \u0027push\u0027];\n if (message) stashArgs.push(\u0027-m\u0027, message);\n if (includeUntracked) stashArgs.push(\u0027--include-untracked\u0027);\n const { stdout } = await execGitCommand(stashArgs);\n if (stdout.includes(\u0027No local changes to save\u0027)) {\n return res.json({ success: false, message: \u0027没有本地更改需要保存\u0027 });\n }\n res.json({ success: true, message: \u0027成功保存工作区更改\u0027, output: stdout });\n } catch (error) {\n // 友好处理:当 Git 返回 \"No valid patches in input\" 时,提示无可储藏更改\n const msg = error?.message || \u0027\u0027;\n if (msg.includes(\u0027No valid patches in input\u0027)) {\n return res.json({ success: false, message: \u0027没有可储藏的更改(可能刚刚已储藏,或被锁定过滤)\u0027 });\n }\n logger.error(\u0027保存stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 保存部分文件的stash\n app.post(\u0027/api/stash-save-partial\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { files, message, includeUntracked } = req.body;\n \n if (!files || !Array.isArray(files) || files.length === 0) {\n return res.json({ success: false, message: \u0027请选择要储藏的文件\u0027 });\n }\n \n // 构建 git stash push 命令\n const stashArgs = [\u0027stash\u0027, \u0027push\u0027];\n if (message) {\n stashArgs.push(\u0027-m\u0027, message);\n }\n if (includeUntracked) {\n stashArgs.push(\u0027--include-untracked\u0027);\n }\n \n // 添加文件列表\n stashArgs.push(\u0027--\u0027, ...files);\n \n const { stdout } = await execGitCommand(stashArgs);\n \n if (stdout.includes(\u0027No local changes to save\u0027)) {\n return res.json({ success: false, message: \u0027没有本地更改需要保存\u0027 });\n }\n \n res.json({\n success: true,\n message: `成功储藏 ${files.length} 个文件`,\n output: stdout\n });\n } catch (error) {\n const msg = error?.message || \u0027\u0027;\n if (msg.includes(\u0027No valid patches in input\u0027)) {\n return res.json({ success: false, message: \u0027没有可储藏的更改\u0027 });\n }\n logger.error(\u0027保存部分stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 应用特定的stash\n app.post(\u0027/api/stash-apply\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId, pop } = req.body;\n \n if (!stashId) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少stash ID参数\u0027\n });\n }\n \n // 决定是使用apply(保留stash)还是pop(应用后删除stash)\n const command = pop ? [\u0027stash\u0027, \u0027pop\u0027, stashId] : [\u0027stash\u0027, \u0027apply\u0027, stashId];\n \n try {\n const { stdout } = await execGitCommand(command);\n \n res.json({\n success: true,\n message: `成功${pop ? \u0027应用并删除\u0027 : \u0027应用\u0027}stash`,\n output: stdout\n });\n } catch (error) {\n // 检查是否有合并冲突\n if (error.message \u0026\u0026 error.message.includes(\u0027CONFLICT\u0027)) {\n return res.status(409).json({\n success: false,\n hasConflicts: true,\n error: \u0027应用stash时发生冲突,需要手动解决\u0027,\n details: error.message\n });\n }\n throw error;\n }\n } catch (error) {\n logger.error(\u0027应用stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 删除特定的stash\n app.post(\u0027/api/stash-drop\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId } = req.body;\n \n if (!stashId) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少stash ID参数\u0027\n });\n }\n \n const { stdout } = await execGitCommand([\u0027stash\u0027, \u0027drop\u0027, stashId]);\n \n res.json({\n success: true,\n message: \u0027成功删除stash\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027删除stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 清空所有stash\n app.post(\u0027/api/stash-clear\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stdout } = await execGitCommand([\u0027stash\u0027, \u0027clear\u0027]);\n \n res.json({\n success: true,\n message: \u0027成功清空所有stash\u0027,\n output: stdout\n });\n } catch (error) {\n logger.error(\u0027清空stash失败:\u0027, error);\n res.status(500).json({ success: false, error: error.message });\n }\n }));\n\n // 获取stash中的文件列表(包含未跟踪文件)\n app.get(\u0027/api/stash-files\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId } = req.query;\n \n if (!stashId) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少stash ID参数\u0027\n });\n }\n \n logger.info(`获取stash文件列表: stashId=${stashId}`);\n \n // 0) 解析出当前 stash 提交及其父提交哈希,避免在 Windows 上使用 ^ 语法\n const { stdout: parentsLine } = await execGitCommand([\u0027rev-list\u0027, \u0027--parents\u0027, \u0027-n\u0027, \u00271\u0027, stashId], { log: false });\n const hashes = parentsLine.trim().split(/\\s+/).filter(Boolean);\n const stashCommit = hashes[0] || \u0027\u0027;\n const parent1 = hashes[1] || \u0027\u0027;\n const parent3 = hashes[3] || \u0027\u0027; // 当包含未跟踪文件时,第三父才存在\n \n // 1) 跟踪文件的变更列表:父1 与 stash 提交的差异(若无父1则为空)\n let trackedFiles = [];\n if (parent1) {\n const { stdout: trackedOut } = await execGitCommand([\u0027diff\u0027, \u0027--name-only\u0027, parent1, stashCommit], { log: false });\n trackedFiles = trackedOut.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean);\n }\n \n // 2) 未跟踪文件:来自第三父(若存在)\n let untrackedFiles = [];\n if (parent3) {\n const { stdout: untrackedOut } = await execGitCommand([\u0027ls-tree\u0027, \u0027-r\u0027, \u0027--name-only\u0027, parent3], { log: false });\n untrackedFiles = untrackedOut.split(\u0027\\n\u0027).map(s =\u003e s.trim()).filter(Boolean);\n }\n \n // 合并并去重\n const fileSet = new Set([ ...trackedFiles, ...untrackedFiles ]);\n const files = Array.from(fileSet);\n logger.info(`找到${files.length}个stash文件(含未跟踪):`, files);\n \n res.json({\n success: true,\n files\n });\n } catch (error) {\n logger.error(\u0027获取stash文件列表失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `获取stash文件列表失败: ${error.message}`\n });\n }\n }));\n\n // 获取stash中特定文件的差异(包含未跟踪文件)\n app.get(\u0027/api/stash-file-diff\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId, file } = req.query;\n \n if (!stashId || !file) {\n return res.status(400).json({\n success: false,\n error: \u0027缺少必要参数\u0027\n });\n }\n \n logger.info(`获取stash文件差异: stashId=${stashId}, file=${file}`);\n \n // 先解析父提交哈希,避免使用 ^ 语法\n const { stdout: parentsLine } = await execGitCommand([\u0027rev-list\u0027, \u0027--parents\u0027, \u0027-n\u0027, \u00271\u0027, stashId], { log: false });\n const hashes = parentsLine.trim().split(/\\s+/).filter(Boolean);\n const stashCommit = hashes[0] || \u0027\u0027;\n const parent1 = hashes[1] || \u0027\u0027;\n const parent3 = hashes[3] || \u0027\u0027;\n \n // 检查该文件是否来自第三父(未跟踪文件)\n let isFromThirdParent = false;\n if (parent3) {\n try {\n await execGitCommand([\u0027cat-file\u0027, \u0027-e\u0027, `${parent3}:${file}`], { log: false });\n isFromThirdParent = true;\n } catch (_) {\n isFromThirdParent = false;\n }\n }\n \n if (isFromThirdParent) {\n // 未跟踪文件:读取第三父中的内容,构造新增文件的统一diff\n const { stdout: blob } = await execGitCommand([\u0027show\u0027, `${parent3}:${file}`], { log: false });\n \n // 检查文件大小\n const sizeCheck = checkDiffSize(blob, 500);\n if (sizeCheck) {\n return res.json({ success: true, ...sizeCheck });\n }\n \n const lines = blob.endsWith(\u0027\\n\u0027) ? blob.slice(0, -1).split(\u0027\\n\u0027) : blob.split(\u0027\\n\u0027);\n const lineCount = lines.length;\n \n // 检查行数\n if (lineCount \u003e 10000) {\n return res.json({\n success: true,\n diff: `⚠️ 变更内容过大 (${lineCount.toLocaleString()} 行),diff已跳过显示以避免浏览器卡顿。\\n\\n提示:建议使用命令行查看大文件变更。`,\n isLargeFile: true,\n stats: { added: lineCount, deleted: 0, total: lineCount }\n });\n }\n \n const plusLines = lines.map(l =\u003e `+${l}`).join(\u0027\\n\u0027);\n const diffText = [\n `diff --git a/${file} b/${file}`,\n `new file mode 100644`,\n `--- /dev/null`,\n `+++ b/${file}`,\n `@@ -0,0 +${lineCount} @@`,\n `${plusLines}`\n ].join(\u0027\\n\u0027);\n \n return res.json({ success: true, diff: diffText });\n }\n \n // 否则,使用原有方式获取与父1的变更\n // checkShouldSkipDiff 接受字符串命令用于日志展示,这里只用于大小判断;\n // 走 execGitCommand 时用 argv 数组,避免 Windows 下 cmd.exe 拼引号被破坏。\n const diffCommandForCheck = `git show ${stashCommit} -- \"${file}\"`;\n const diffCommandArgs = [\u0027show\u0027, stashCommit, \u0027--\u0027, file];\n \n // 使用优化的检查函数\n const skipCheck = await checkShouldSkipDiff(file, diffCommandForCheck);\n if (skipCheck.shouldSkip) {\n return res.json({\n success: true,\n diff: skipCheck.reason,\n isLargeFile: true,\n stats: skipCheck.stats\n });\n }\n \n const { stdout } = await execGitCommand(diffCommandArgs);\n \n logger.info(`获取到差异内容,长度: ${stdout.length}`);\n \n // 检查实际diff大小\n const sizeCheck = checkDiffSize(stdout, 500);\n if (sizeCheck) {\n return res.json({ success: true, ...sizeCheck });\n }\n \n // 统计增加和删除行数\n const stats = getDiffStats(stdout);\n \n res.json({ success: true, diff: stdout, stats });\n } catch (error) {\n logger.error(\u0027获取stash文件差异失败:\u0027, error);\n res.status(500).json({\n success: false,\n error: `获取stash文件差异失败: ${error.message}`\n });\n }\n }));\n\n // 获取stash中特定文件的完整内容对比(原始 vs 储藏后)\n app.get(\u0027/api/stash-file-compare\u0027, asyncRoute(async (req, res) =\u003e {\n try {\n const { stashId, file } = req.query;\n \n if (!stashId || !file) {\n throw new HttpError(400, \u0027缺少必要参数\u0027);\n }\n \n // 解析父提交哈希\n const { stdout: parentsLine } = await execGitCommand([\u0027rev-list\u0027, \u0027--parents\u0027, \u0027-n\u0027, \u00271\u0027, stashId], { log: false });\n const hashes = parentsLine.trim().split(/\\s+/).filter(Boolean);\n const stashCommit = hashes[0] || \u0027\u0027;\n const parent1 = hashes[1] || \u0027\u0027;\n const parent3 = hashes[3] || \u0027\u0027;\n \n // 检查是否为未跟踪文件(来自第三父)\n let isFromThirdParent = false;\n if (parent3) {\n try {\n await execGitCommand([\u0027cat-file\u0027, \u0027-e\u0027, `${parent3}:${file}`], { log: false });\n isFromThirdParent = true;\n } catch (_) {\n isFromThirdParent = false;\n }\n }\n \n // 获取原始内容(储藏前,来自 parent1)\n let original = \u0027\u0027;\n if (!isFromThirdParent \u0026\u0026 parent1) {\n try {\n const { stdout: origOut } = await execGitCommand([\u0027show\u0027, `${parent1}:${file}`], { log: false });\n original = origOut ?? \u0027\u0027;\n } catch (_) {\n original = \u0027\u0027; // 文件在储藏前不存在\n }\n }\n \n // 获取储藏后的内容\n let modified = \u0027\u0027;\n const modRef = isFromThirdParent ? parent3 : stashCommit;\n if (modRef) {\n try {\n const { stdout: modOut } = await execGitCommand([\u0027show\u0027, `${modRef}:${file}`], { log: false });\n modified = modOut ?? \u0027\u0027;\n } catch (_) {\n modified = \u0027\u0027; // 文件已被删除\n }\n }\n \n res.json({ success: true, original, modified });\n } catch (error) {\n logger.error(\u0027获取stash文件对比失败:\u0027, error);\n res.status(500).json({ success: false, error: `获取stash文件对比失败: ${error.message}` });\n }\n }));\n}\n","structuredPatch":[{"oldStart":14,"oldLines":7,"newStart":14,"newLines":7,"lines":[" //"," import { createDiffHelpers } from \u0027./diffUtils.js\u0027;"," import logger from \u0027../../utils/logger.js\u0027","-import { asyncRoute, HttpError } from \u0027../utils/asyncRoute.js\u0027;","+import { asyncRoute, HttpError } from \u0027../../utils/asyncRoute.js\u0027;"," "," export function registerGitStashRoutes({ app, execGitCommand, configManager }) {"," const { checkShouldSkipDiff, checkDiffSize, getDiffStats } = createDiffHelpers({ execGitCommand });"]}],"userModified":false,"replaceAll":false},"tool_use_id":"call_019f06c4c1bc7c6396cbac12","duration_ms":6,"timestamp":"2026-06-27 09:49:48"}