@xcanwin/manyoyo 8.6.7 → 8.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/manyoyo.js +1 -0
- package/lib/agent-instructions.js +50 -0
- package/lib/plugin/playwright.js +4 -2
- package/lib/setup-config.js +6 -1
- package/lib/web/AGENTS.md +5 -0
- package/lib/web/git-changes.js +130 -0
- package/lib/web/index.html +86 -80
- package/lib/web/server.js +383 -28
- package/lib/web/session-meta.js +117 -0
- package/manyoyo.example.json +2 -0
- package/package.json +2 -1
package/bin/manyoyo.js
CHANGED
|
@@ -348,6 +348,7 @@ function installServeProcessDiagnostics(logger) {
|
|
|
348
348
|
* @property {string[]} [volumes] - 挂载卷数组
|
|
349
349
|
* @property {Object.<string, Object>} [plugins] - 可选插件配置映射(如 plugins.playwright)
|
|
350
350
|
* @property {{title?:string}} [serve] - serve 命令专属配置(如自定义网页标题,留空字符串则显示为空)
|
|
351
|
+
* @property {string} [agentInstructions] - 网页里执行 Agent 时追加给它的说明(接在内置环境说明后面;runs.<name> 可覆盖)
|
|
351
352
|
* @property {Object.<string, Object>} [runs] - 运行配置映射(-r <name>)
|
|
352
353
|
* @property {string} [yolo] - YOLO 模式
|
|
353
354
|
* @property {string} [containerMode] - 容器模式
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Web 端执行 Agent 时追加给它的环境说明(唯一数据源)。
|
|
4
|
+
// 只写环境事实、用条件句:不鼓励它主动生成或展示图片,也不重复模型本来就知道的事。
|
|
5
|
+
const BUILTIN_AGENT_INSTRUCTIONS = [
|
|
6
|
+
'你的回复会显示在 MANYOYO 网页里(用户可能在手机上看),Markdown 会被渲染。',
|
|
7
|
+
'- 需要让用户看某张图片时(如截图、生成的图表),用 Markdown 图片语法写容器内的绝对路径:,支持 png/jpg/jpeg/gif/webp/bmp/ico。没有必要时不要专门生成或展示图片。',
|
|
8
|
+
'- 提到文件时写容器内的绝对路径,用户可以点开查看。'
|
|
9
|
+
].join('\n');
|
|
10
|
+
|
|
11
|
+
const USER_INSTRUCTIONS_MAX_CHARS = 4000;
|
|
12
|
+
|
|
13
|
+
function pickUserInstructions(config, runName) {
|
|
14
|
+
const source = config && typeof config === 'object' ? config : {};
|
|
15
|
+
const runs = source.runs && typeof source.runs === 'object' ? source.runs : {};
|
|
16
|
+
const run = runName && Object.prototype.hasOwnProperty.call(runs, runName) ? runs[runName] : null;
|
|
17
|
+
if (run && typeof run === 'object' && typeof run.agentInstructions === 'string') {
|
|
18
|
+
return run.agentInstructions;
|
|
19
|
+
}
|
|
20
|
+
return typeof source.agentInstructions === 'string' ? source.agentInstructions : '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 去掉控制字符(保留换行、制表符):codex 的 TOML 字符串不接受它们,也没有正当用途;
|
|
24
|
+
// {prompt} 会被命令模板当成占位符替换,拆开写
|
|
25
|
+
function sanitizeUserInstructions(text) {
|
|
26
|
+
return String(text || '')
|
|
27
|
+
.replace(/\r\n?/g, '\n')
|
|
28
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
|
|
29
|
+
.replace(/\{prompt\}/g, '{ prompt }')
|
|
30
|
+
.trim()
|
|
31
|
+
.slice(0, USER_INSTRUCTIONS_MAX_CHARS);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildAgentInstructions(config, runName) {
|
|
35
|
+
const extra = sanitizeUserInstructions(pickUserInstructions(config, runName));
|
|
36
|
+
return extra ? `${BUILTIN_AGENT_INSTRUCTIONS}\n\n${extra}` : BUILTIN_AGENT_INSTRUCTIONS;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// TOML 基本字符串:JSON 的转义(\" \\ \n \t \uXXXX)在 TOML 里含义相同;
|
|
40
|
+
// 控制字符已在 sanitize 时去掉,这里只剩 JSON 会原样输出、TOML 也允许的字符
|
|
41
|
+
function toTomlBasicString(text) {
|
|
42
|
+
return JSON.stringify(String(text || ''));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
BUILTIN_AGENT_INSTRUCTIONS,
|
|
47
|
+
buildAgentInstructions,
|
|
48
|
+
sanitizeUserInstructions,
|
|
49
|
+
toTomlBasicString
|
|
50
|
+
};
|
package/lib/plugin/playwright.js
CHANGED
|
@@ -911,7 +911,9 @@ class PlaywrightPlugin {
|
|
|
911
911
|
|
|
912
912
|
// 真实探测当前模式(不切换模式、失败也不回退;发布流水线用它:status 在失败时会把模式回退成默认;默认模式会按需重建 current/ 里的配置文件)。
|
|
913
913
|
// 宿主机上的模式连上浏览器服务开页面;默认模式(容器内浏览器)起一个一次性容器,真的打开页面并读回标题
|
|
914
|
-
|
|
914
|
+
// 命令行的 `manyoyo playwright probe`。不能叫 probe:类里同名方法后定义的会覆盖底层的 probe(kind, endpoint),
|
|
915
|
+
// up 里的 probeState 就会调回这里,无限递归(up headed / vnc 永远不返回)
|
|
916
|
+
async probeCommand() {
|
|
915
917
|
const state = this.readState();
|
|
916
918
|
if (state.mode !== 'default') {
|
|
917
919
|
const alive = await this.modeAlive(state);
|
|
@@ -984,7 +986,7 @@ class PlaywrightPlugin {
|
|
|
984
986
|
return await this.status();
|
|
985
987
|
}
|
|
986
988
|
if (action === 'probe') {
|
|
987
|
-
return await this.
|
|
989
|
+
return await this.probeCommand();
|
|
988
990
|
}
|
|
989
991
|
if (action === 'logs') {
|
|
990
992
|
return this.logs();
|
package/lib/setup-config.js
CHANGED
|
@@ -29,4 +29,9 @@ function buildMirrorsConfigRaw(baseRaw, mirrors) {
|
|
|
29
29
|
return upsertValueByPath(baseRaw, ['mirrors'], JSON.stringify(mirrors, null, 4));
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
// 网页里执行 Agent 时追加的说明(只写全局配置;runs.<name> 的覆盖在 JSON 里手写)
|
|
33
|
+
function buildAgentInstructionsConfigRaw(baseRaw, text) {
|
|
34
|
+
return upsertValueByPath(baseRaw, ['agentInstructions'], JSON.stringify(String(text || '')));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { EMPTY_CONFIG_RAW, baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw };
|
package/lib/web/AGENTS.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
- **追加提示词(排队)**:任务进行中用户可以追加提示词,队列存在 `agentSession.queue`(`lib/web/agent-queue.js`,最多 10 条,`when` 为 `now` / `step` / `turn`;`normalizeWebAgentSession` 的字段白名单里要有它,否则落盘后丢失)。接口 `POST /api/sessions/:name/agent/queue`(clientMessageId 幂等;容器被别的会话占着返回 409)、`DELETE .../agent/queue/:id`、`POST .../agent/queue/continue`;`/messages` 带 `queue`。一轮对话的完整流程抽成 `runWebAgentStreamTurn(..., res)`:前端不在线时传 `createHeadlessResponse()` 顶替 `res`,事件照常落盘。每一轮结束(完成 / 失败 / 被追加打断,**用户点停止除外**,由 `stopWebAgentRun` 置 `runState.userStopped`,并把 `agentSession.queuePaused` **落盘**——只记在内存里的话,serve 重启或用户下一次正常发送、那一轮结束后,停止前的排队会被自动发出去;暂停只有 `POST .../agent/queue/continue` 能解除,队列清空时自动解除,`/messages` 带 `queuePaused`)后若队列可以自动发(`agentQueue.shouldAutoDispatch`),先给连着的流发一个 `queue_next` 事件(前端据此结束这条流、改走恢复轮询),再 `setImmediate` 调 `dispatchNextQueued`(同步取出队首并占锁,开不起来就放回队首)。`now` 立即走进程树信号;`step` 在 `isStepBoundary`(一次工具 / 子 Agent / 说明完成)处打断,`agentStepFallbackMs`(默认 120 秒)等不到就退化成 `now`。serve 重启后 `startContainerServices` 末尾调 `resumeQueuedAgentTurns`,把留在队列里(暂停的除外)又没有任务在跑的内容当作 `turn` 发出;发之前用 `buildLiveRunsCommand`(只读,校验规则同停止脚本)看容器里还有没有活着的 Agent 进程,有就不发、排队留给用户——那可能是上一个 serve 的孤儿任务,也可能是同一 HOME 下另一个 serve 实例正在跑的任务,所以只检测不杀。
|
|
20
20
|
- **停止 / 打断必须进到容器里发信号**(`lib/web/agent-run-control.js`):只杀宿主机上的 `docker exec` 客户端,容器里的 Agent 进程会继续跑(实测)。命令被包成 `echo $$ > /tmp/manyoyo-run-<id>.pid; <命令>; exit $?`,**末尾的 `exit $?` 不能省**——命令是最后一条简单命令时 bash 会 exec 替换自己,`/proc/<pid>/cmdline` 里的 `manyoyo-run-<id>.pid` 标记就没了,停止时会因“认不出进程”而不杀(真实容器测出来的,单测里用 `sleep 61; true` 测不到)。PID 文件容器里可写,按不可信输入处理(纯数字、普通文件、命令行带标记才发信号),再按子孙进程树逐级 INT → TERM → KILL。真实的 claude 收到 SIGINT 会正常收尾、**退出码为 0**,所以 `interrupted` 看“发过信号”(`runState.signaled`)而不只是退出码。已验证 Claude 打断后 `-r <id>` 能接上上下文;Codex / Gemini / OpenCode 没有环境验证,拿不到引擎会话 id 时走既有的历史注入降级。
|
|
21
21
|
- **子 Agent**:Claude 的 `Agent`(旧名 `Task`)工具生成 `kind: "subagent"` 事件,子 Agent 里的事件带 `parentToolId`(取自 `parent_tool_use_id`);带 `parent_tool_use_id` 的 assistant 文本不当作主 Agent 的回复。`--forward-subagent-text` 只在 `claude --help` 里有该参数时才加(命令里就地探测,约 50ms,不缓存)。样本 fixture:`test/fixtures/claude-subagent.ndjson`。
|
|
22
|
+
- **最终回复的取值**(`resolveAgentFinalOutput`):原始 stdout 只保留开头 `AGENT_RAW_OUTPUT_MAX_CHARS`(32MB,会话 id 在开头),另外用 `createTailBuffer` 保留结尾(8MB)。最终回复和用量都在结尾:超过上限后只解析开头,会把截断点之前的某条中间说明当成最终回复(每个 token 一行约 260 字节的增量,加上思考 token、子 Agent、大段工具结果,长任务很容易超过);所以截断时先从结尾提取,提取不到再看开头,用量也优先看结尾(`stdoutTail`)。Claude 以最后一个 `result` 事件的 `result` 为准(后台任务场景结尾有两个 `result`,第一个是中间结论)。提取到的最终回复**不截断**(与流式落盘一致),提取不到才退回原始输出并按 `WEB_OUTPUT_MAX_CHARS` 截断。子进程的 stdout / stderr 必须 `setEncoding('utf8')` 按流解码:逐块 `toString` 会把跨块的中文拆成乱码。测试可用 `agentRawOutputMaxChars` 调小上限;真实样本 `test/fixtures/claude-background-task.ndjson`、`claude-subagent-parallel.ndjson`(claude 2.1.290,已精简)。
|
|
22
23
|
- **历史读写在 `lib/web/history-store.js`**:进程内缓存 + 原子写(`writeConfigFileSecure`,0600)+ 合并写盘。读靠 `stat` 的 `ino + mtimeMs + size` 判断缓存是否还有效(别的进程原子替换也能识别);`loadWebSessionHistory` 返回的是**缓存里的同一个对象**,改了必须 `saveWebSessionHistory`,只读路由不要改它(`getWebAgentSession(..., { create: true })` 会在缓存里留一个未保存的空 agent,只在马上要写的路径里用)。流式过程中的 trace patch 和控制事件走 `{ defer: true }`(只改内存,最迟 `AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS` 后落盘);`process.exit` / serve 关闭会 flush;`result` / `error` 类事件(`WEB_STREAM_FLUSH_EVENT_TYPES`)和所有非 defer 写入立即写盘并带上攒着的内容。`kill -9` 最多丢约 1 秒的 trace。文件解析失败会留一份 `.corrupt-<时间>` 备份再按空历史处理,不要绕过它。磁盘格式只有 `agents.<id>.messages`(不再有顶层 `messages`,紧凑 JSON),老版本(含 `update --rollback`)读取时优先用 `agents`,能读新文件。
|
|
23
24
|
- `GET /api/sessions` 以前是同步 IO 大户(逐容器读 + 解析整份历史),现在命中缓存基本是 0 次解析,但新增的同步操作仍要先实测;`buildSessionSummary()` 支持传入调用方加载好的 history,不要重复读。(`docker ps -a` + 批量 `inspect` 实测仅约 90ms,不是瓶颈)
|
|
24
25
|
- **`/messages` 是分页 + 增量**(`lib/web/messages-view.js`):`?limit=`(1~200,默认 60,起点往前对齐到 user 消息)/ `?after=<id>&epoch=<e>`(tail,锚点之后的消息;epoch 不符、锚点不存在或超过 120 条时返回最近一页并标 `mode: "reset"`)/ `?before=<id>`(更早一页);`GET /messages/<id>/trace` 取完整执行过程。查询参数全部白名单校验,非法返回 400。**稳定前缀约定**:第一条 pending 消息之前的消息不再被修改——违反约定的写操作(删除非 pending 消息、克隆)必须给该 agent 换新的 `epoch`(`removeWebSessionMessage` 与 clone 已处理;新增修改历史消息的写操作要自己判断)。500 条上限从头裁掉**不换** epoch(tail 响应带 `firstId`,前端据此裁掉本地被丢弃的开头)。列表里的 trace 消息会被精简(长字符串截成 200 字符,带 `traceTruncated`),`/audit` 与 trace 接口输出完整内容。
|
|
@@ -29,6 +30,10 @@
|
|
|
29
30
|
- **终端 WebSocket**:`/api/sessions/:name/terminal/ws`,在 `server.on('upgrade')` 里做 Origin 校验 + 认证 + 容器名校验,再交给 `bindTerminalWebSocket()`。上行 `input` / `resize` / `ping` / `close`,下行 `output` / `status`(`ready` / `closed`)/ `error` / `pong`;实现是 `docker exec` 里用 `script` 或 python `pty` 引导出的伪 TTY。**动态 resize**:bootstrap 的 rc 把 `tty` 输出写进容器里 `/tmp/manyoyo-term-<随机id>.tty`;收到 `resize` 后(整数、10~500 列、4~200 行,否则整条忽略;下限比建连初值 40×12 低,因为手机竖屏不到 40 列;节流,同一时刻只有一个 exec)用固定脚本 `TERMINAL_RESIZE_SCRIPT` 在容器里读该文件,内容必须整行匹配 `/dev/pts/<数字>` 才执行 `stty -F`。**这个 tty 文件容器里的进程可写,只能当不可信输入**(脚本只认普通文件、不跟符号链接、最多读 64 字节),用例见 `test/web-terminal-resize.test.js`。降级成非 TTY 时 resize 自然无效。并发上限 `WEB_TERMINAL_MAX_SESSIONS = 20`,每条连接对应一个 `ptyProcess`,`ws` 关闭即 `SIGTERM`——所以前端任何「卸载终端组件」的改动都等于杀掉用户正在跑的 shell。
|
|
30
31
|
- 空闲保活是 `/agent/stream` 那条心跳规则的同类问题,**两个入口都要守**:终端侧服务端每 `WEB_TERMINAL_PING_INTERVAL_MS`(30s)发 WebSocket ping 帧,连续 `WEB_TERMINAL_MAX_MISSED_PONGS`(3 次,约 90s)无 pong 才判死;前端另发应用层 `ping`(浏览器 JS 发不出 ping 帧,上行需要自己造流量)。判死阈值不要收紧到一个周期:手机切后台会让连接短暂挂起,误杀等于用户的 shell 没了。升级后的 socket 还要 `setTimeout(0)` + `setKeepAlive`,解除 HTTP 侧空闲超时。
|
|
31
32
|
- 新增接口/页面必须走全局认证网关,禁止在业务路由里零散补认证;匿名白名单见根 `AGENTS.md` 的安全约束。
|
|
33
|
+
- **环境说明**(`lib/agent-instructions.js` 唯一数据源,`buildWebAgentExecCommand` 的 `options.instructions`):每次执行时现读配置(`runs.<快照里的 run 名>.agentInstructions` > 全局),claude 用 `--append-system-prompt`、codex 用 `-c developer_instructions=<TOML 字符串>`、opencode 把说明写进容器 `/tmp/manyoyo-agent-instructions.md` 再经 `OPENCODE_CONFIG_CONTENT` 合并(1.19.3 实测与用户的 instructions 拼接);gemini 只有 `GEMINI_SYSTEM_MD` 这种整体替换,不注入。用户模板里已有同名参数 / 变量就不注入;用户追加的文本去控制字符、把 `{prompt}` 拆开(否则会被模板替换成提示词)。CLI 的 `manyoyo run` 是交互终端,不注入。
|
|
34
|
+
- **会话标题**:claude 交互模式写 `ai-title`、`/rename` 写 `custom-title`(优先),**`-p` 下实测不生成 `ai-title`**(2.1.290,3 轮)。每轮结束后 `refreshWebAgentTitle` 在后台只读会话 jsonl 尾部 256KB(session id 按 UUID 校验),失败静默不写日志;`firstPrompt` 在第一次追加 Agent 用户消息时记下(历史会被裁剪,不能现算)。这些字段与 `pinned`、`slashCommands` 都要同时加进 `createEmptyWebAgentSession` 和 `normalizeWebAgentSessionRecord`,否则落盘丢失。
|
|
35
|
+
- **斜杠命令候选**(`GET .../slash-commands`):来自 claude 每次运行 init 事件的 `slash_commands` / `skills` / `terminal_slash_commands`,只列 skill 和 `HEADLESS_BUILTIN_COMMANDS`(`-p` 下逐个实测过;`compact` 在 `-p` 下不压缩,别加回去)。
|
|
36
|
+
- **改动视图**(`lib/web/git-changes.js`,`GET .../git/changes`、`.../git/diff?path=`):按需执行、不轮询;仓库和 git 配置由容器控制,命令里关掉外部 diff / textconv / fsmonitor、`GIT_OPTIONAL_LOCKS=0`、输出有上限;diff 的 path 必须出现在服务端重新取的改动列表里,`-` 开头和换行 / NUL 一律拒绝。
|
|
32
37
|
|
|
33
38
|
## 容器管理接口
|
|
34
39
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// “改动”视图:在容器里对工作目录所在的 git 仓库跑只读命令(status / diff),按需执行、不轮询。
|
|
4
|
+
// 仓库内容和 git 配置都由容器内进程控制,按不可信输入处理:关掉会执行外部程序的配置
|
|
5
|
+
//(外部 diff、textconv、fsmonitor),不写 index(GIT_OPTIONAL_LOCKS=0),输出有上限。
|
|
6
|
+
|
|
7
|
+
const CHANGES_MAX_FILES = 500;
|
|
8
|
+
const DIFF_MAX_BYTES = 512 * 1024;
|
|
9
|
+
const GIT_TIMEOUT_SECONDS = 10;
|
|
10
|
+
const STATUS_MARK = '__MANYOYO_GIT_STATUS__';
|
|
11
|
+
const NUMSTAT_MARK = '__MANYOYO_GIT_NUMSTAT__';
|
|
12
|
+
const ROOT_MARK = '__MANYOYO_GIT_ROOT__';
|
|
13
|
+
|
|
14
|
+
function quote(value) {
|
|
15
|
+
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const GIT = `GIT_OPTIONAL_LOCKS=0 GIT_LITERAL_PATHSPECS=1 timeout ${GIT_TIMEOUT_SECONDS} git -c core.quotepath=off -c core.fsmonitor=false -c diff.external= -c color.ui=false`;
|
|
19
|
+
|
|
20
|
+
// 进到工作目录所在仓库的根目录;不是仓库时只输出一行 NOT_REPO
|
|
21
|
+
function enterRepo(workDir) {
|
|
22
|
+
return [
|
|
23
|
+
`cd -- ${quote(workDir || '/')} 2>/dev/null || { echo NOT_REPO; exit 0; }`,
|
|
24
|
+
`top="$(${GIT} rev-parse --show-toplevel 2>/dev/null)" || { echo NOT_REPO; exit 0; }`,
|
|
25
|
+
'cd -- "$top" || { echo NOT_REPO; exit 0; }'
|
|
26
|
+
].join('; ');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildGitChangesCommand(workDir) {
|
|
30
|
+
return [
|
|
31
|
+
enterRepo(workDir),
|
|
32
|
+
`printf '%s\\n%s\\n' ${ROOT_MARK} "$top"`,
|
|
33
|
+
`printf '%s\\n' ${STATUS_MARK}`,
|
|
34
|
+
`${GIT} status --porcelain=v1 -z --no-renames --untracked-files=all 2>/dev/null | head -c 1048576`,
|
|
35
|
+
`printf '\\n%s\\n' ${NUMSTAT_MARK}`,
|
|
36
|
+
`if ${GIT} rev-parse --verify -q HEAD >/dev/null 2>&1; then ${GIT} diff HEAD --numstat -z --no-renames --no-ext-diff --no-textconv 2>/dev/null | head -c 1048576; fi`
|
|
37
|
+
].join('; ');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sectionAfter(output, mark, nextMark) {
|
|
41
|
+
const start = output.indexOf(`${mark}\n`);
|
|
42
|
+
if (start < 0) return '';
|
|
43
|
+
const from = start + mark.length + 1;
|
|
44
|
+
const end = nextMark ? output.indexOf(`\n${nextMark}\n`, from) : -1;
|
|
45
|
+
return end < 0 ? output.slice(from) : output.slice(from, end);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseGitChangesOutput(output) {
|
|
49
|
+
const text = String(output || '');
|
|
50
|
+
if (!text.includes(ROOT_MARK)) {
|
|
51
|
+
return { repo: false, root: '', files: [], truncated: false };
|
|
52
|
+
}
|
|
53
|
+
const root = sectionAfter(text, ROOT_MARK, STATUS_MARK).split('\n')[0].trim();
|
|
54
|
+
const statusPart = sectionAfter(text, STATUS_MARK, NUMSTAT_MARK);
|
|
55
|
+
const numstatPart = sectionAfter(text, NUMSTAT_MARK, '');
|
|
56
|
+
const counts = new Map();
|
|
57
|
+
for (const record of numstatPart.split('\0')) {
|
|
58
|
+
const match = record.replace(/^\n/, '').match(/^(-|\d+)\t(-|\d+)\t(.+)$/s);
|
|
59
|
+
if (match) {
|
|
60
|
+
counts.set(match[3], {
|
|
61
|
+
added: match[1] === '-' ? null : Number(match[1]),
|
|
62
|
+
deleted: match[2] === '-' ? null : Number(match[2])
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const files = [];
|
|
67
|
+
let truncated = false;
|
|
68
|
+
for (const record of statusPart.split('\0')) {
|
|
69
|
+
if (record.length < 4 || record[2] !== ' ') continue;
|
|
70
|
+
const code = record.slice(0, 2);
|
|
71
|
+
const filePath = record.slice(3);
|
|
72
|
+
if (!filePath || filePath.includes('\n')) continue;
|
|
73
|
+
if (files.length >= CHANGES_MAX_FILES) {
|
|
74
|
+
truncated = true;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
const count = counts.get(filePath) || { added: null, deleted: null };
|
|
78
|
+
files.push({ path: filePath, status: code === '??' ? 'untracked' : describeStatus(code), ...count });
|
|
79
|
+
}
|
|
80
|
+
return { repo: true, root, files, truncated };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function describeStatus(code) {
|
|
84
|
+
if (code.includes('D')) return 'deleted';
|
|
85
|
+
if (code.includes('A')) return 'added';
|
|
86
|
+
if (code.includes('U')) return 'conflicted';
|
|
87
|
+
return 'modified';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isAcceptableDiffPath(filePath) {
|
|
91
|
+
return typeof filePath === 'string'
|
|
92
|
+
&& filePath.length > 0
|
|
93
|
+
&& filePath.length <= 4096
|
|
94
|
+
&& !filePath.startsWith('-')
|
|
95
|
+
&& !filePath.includes('\0')
|
|
96
|
+
&& !filePath.includes('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildGitDiffCommand(workDir, filePath) {
|
|
100
|
+
return [
|
|
101
|
+
enterRepo(workDir),
|
|
102
|
+
`printf '%s\\n' ${STATUS_MARK}`,
|
|
103
|
+
`if ${GIT} rev-parse --verify -q HEAD >/dev/null 2>&1; then base=HEAD; else base=; fi`,
|
|
104
|
+
`${GIT} diff $base --no-ext-diff --no-textconv --no-color --no-renames -- ${quote(filePath)} 2>/dev/null | head -c ${DIFF_MAX_BYTES + 1}`
|
|
105
|
+
].join('; ');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseGitDiffOutput(output) {
|
|
109
|
+
const text = String(output || '');
|
|
110
|
+
if (!text.includes(STATUS_MARK)) {
|
|
111
|
+
return { repo: false, diff: '', truncated: false };
|
|
112
|
+
}
|
|
113
|
+
const body = sectionAfter(text, STATUS_MARK, '');
|
|
114
|
+
const truncated = Buffer.byteLength(body, 'utf8') > DIFF_MAX_BYTES;
|
|
115
|
+
return {
|
|
116
|
+
repo: true,
|
|
117
|
+
diff: truncated ? Buffer.from(body, 'utf8').subarray(0, DIFF_MAX_BYTES).toString('utf8') : body,
|
|
118
|
+
truncated
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = {
|
|
123
|
+
CHANGES_MAX_FILES,
|
|
124
|
+
DIFF_MAX_BYTES,
|
|
125
|
+
buildGitChangesCommand,
|
|
126
|
+
parseGitChangesOutput,
|
|
127
|
+
buildGitDiffCommand,
|
|
128
|
+
parseGitDiffOutput,
|
|
129
|
+
isAcceptableDiffPath
|
|
130
|
+
};
|