dsh-mobile-plugin 0.1.95 → 0.1.97
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/README.md +73 -50
- package/config/agent-presets/standard/agent.cordis.yml +13 -0
- package/dsh-mobile-credentials/lib/credentials.js +24 -1
- package/lib/git/exec.js +129 -0
- package/lib/git/package.json +3 -0
- package/lib/git/parse.js +247 -0
- package/lib/git/read.js +617 -0
- package/lib/impl.js +2 -2
- package/lib/mirror/pusher.cjs +25 -2
- package/lib/mobile/archive-clean.js +31 -1
- package/lib/mobile/channels.js +583 -0
- package/lib/mobile/events.js +10 -8
- package/lib/mobile/git-repos.js +210 -0
- package/lib/mobile/git.js +603 -0
- package/lib/mobile/history-derive.js +79 -1
- package/lib/mobile/host-bridge/endpoints.js +3 -0
- package/lib/mobile/host-bridge/event-synthesis.js +109 -1
- package/lib/mobile/host-bridge/shims.js +9 -2
- package/lib/mobile/host-bridge/transport.js +30 -1
- package/lib/mobile/host-settings.js +41 -0
- package/lib/mobile/inbox-clean.js +337 -0
- package/lib/mobile/pairing.js +18 -1
- package/lib/mobile/questions.js +31 -9
- package/lib/mobile/schedule.js +440 -0
- package/lib/mobile/session-cache.js +18 -0
- package/lib/mobile/session-file.js +192 -0
- package/lib/mobile/session-model-options.js +184 -0
- package/lib/mobile/session-models.js +24 -2
- package/lib/mobile/session-schedule-mark.js +70 -0
- package/lib/mobile/session-snapshot.js +12 -1
- package/lib/mobile/sessions.js +8 -0
- package/lib/mobile/stats-collect.cjs +257 -0
- package/lib/mobile/stats-ledger.cjs +407 -0
- package/lib/mobile/stats.js +38 -0
- package/lib/mobile/stream.js +18 -10
- package/lib/mobile/uploads.js +24 -6
- package/lib/mobile/workspaces.js +30 -6
- package/lib/panel/index.html +4 -4
- package/lib/relay/lan-proxy.js +10 -1
- package/lib/routes/control-plane.js +38 -0
- package/lib/routes/mobile-api.js +70 -1
- package/lib/session-cleanup.cjs +8 -0
- package/lib/shared/sse-write.js +17 -6
- package/lib/shell/updater.js +3 -3
- package/package.json +3 -2
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端 Git 管理器:十一条 `/api/mobile/v1/git/*` 的 handler。
|
|
5
|
+
*
|
|
6
|
+
* 能力面刻意收成**固定命名的动词**,不接受「传一条 git 命令」:这条 API 的边界是不暴露
|
|
7
|
+
* shell,而 `git -c core.pager=less ...` 这种透传等于把 shell 交出去。所以这里只认
|
|
8
|
+
* 「仓库摘要 / 变更清单 / 单个文件的 diff / 历史 / 单次提交 / 分支 / 概览」加上
|
|
9
|
+
* 「暂存 / 取消暂存 / 提交 / 切分支 / 新建分支 / 推送到上游」这些动作,参数只有仓库、
|
|
10
|
+
* 相对路径、分支名、提交信息四类值(push 连分支名都不收:只推当前分支到它自己的上游)。
|
|
11
|
+
*
|
|
12
|
+
* 归属校验在这一层做两遍:① `workspaceId` 必须是这台设备已注册的工作区(宿主 `workspace.list`
|
|
13
|
+
* 里找得到);② 请求里的仓库根必须是 `lib/mobile/git-repos.js` 给这个项目识别出来的那几个根
|
|
14
|
+
* 之一 —— 于是「随便传一个路径」这条路根本走不通,`..`、绝对路径、仓库外的目录都进不来。
|
|
15
|
+
*
|
|
16
|
+
* 写操作按仓库串行(`withRepoLock`):手机上连点两下就是两个并发写,撞上 `.git/index.lock`
|
|
17
|
+
* 会互相踩死;写完顺手回一份新的变更清单,省掉前端一次往返。
|
|
18
|
+
*
|
|
19
|
+
* 明确不做:合并 / 变基 / 拣选 / 冲突解决 / 强制推送 / 删除远端分支 / 改配置与凭据 /
|
|
20
|
+
* 子模块管理 / fetch、pull。**push 只做最窄的一档**(2026-10-22):推当前分支到它已有的上游,
|
|
21
|
+
* 不接受 `--force`、不收分支名参数 —— 远端分叉、没有上游一律拒掉并叫用户回电脑处理,
|
|
22
|
+
* 因为「拉下来再合」这件事本来就留在电脑上。丢弃改动也没做 —— 那是不可恢复的删工作,
|
|
23
|
+
* 接口留白比给一个能被误按的按钮安全。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const { isAbsolute, resolve } = require('node:path');
|
|
27
|
+
|
|
28
|
+
const { readJsonBody, sendJson } = require('../shared/http-util');
|
|
29
|
+
const { unwrap } = require('./envelope.js');
|
|
30
|
+
const { describeProjectGit } = require('./git-repos.js');
|
|
31
|
+
const read = require('../git/read');
|
|
32
|
+
const { runGit, withRepoLock } = require('../git/exec');
|
|
33
|
+
|
|
34
|
+
/** 错误码 → HTTP 状态。前端只认 code,message 只用于排障与兜底文案。 */
|
|
35
|
+
const STATUS_BY_CODE = {
|
|
36
|
+
BAD_REQUEST: 400,
|
|
37
|
+
PATH_OUTSIDE: 400,
|
|
38
|
+
EMPTY_MESSAGE: 400,
|
|
39
|
+
NOTHING_STAGED: 400,
|
|
40
|
+
NOTHING_TO_COMMIT: 400,
|
|
41
|
+
IDENTITY_MISSING: 400,
|
|
42
|
+
FORBIDDEN: 403,
|
|
43
|
+
NOT_FOUND: 404,
|
|
44
|
+
NOT_A_REPO: 404,
|
|
45
|
+
DIRTY: 409,
|
|
46
|
+
DETACHED: 409,
|
|
47
|
+
NO_UPSTREAM: 409,
|
|
48
|
+
REJECTED: 409,
|
|
49
|
+
AUTH_FAILED: 403,
|
|
50
|
+
CONFLICT: 409,
|
|
51
|
+
LOCKED: 409,
|
|
52
|
+
HOOK_FAILED: 409,
|
|
53
|
+
LLM_FAILED: 502,
|
|
54
|
+
GIT_FAILED: 500,
|
|
55
|
+
WORKSPACE_UNAVAILABLE: 502,
|
|
56
|
+
REMOTE_UNAVAILABLE: 502,
|
|
57
|
+
GIT_UNAVAILABLE: 503,
|
|
58
|
+
LLM_UNAVAILABLE: 503,
|
|
59
|
+
TIMEOUT: 504,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
class GitRequestError extends Error {
|
|
63
|
+
constructor(code, message) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = 'GitRequestError';
|
|
66
|
+
this.code = code;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fail(code, message) {
|
|
71
|
+
return new GitRequestError(code, message);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 统一出包:错误码是契约,HTTP 状态只是顺带。 */
|
|
75
|
+
function handle(fn) {
|
|
76
|
+
return async (ctx, req, res) => {
|
|
77
|
+
try {
|
|
78
|
+
await fn(ctx, req, res);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
const code = error && typeof error.code === 'string' ? error.code : 'GIT_FAILED';
|
|
81
|
+
const status = STATUS_BY_CODE[code] || 500;
|
|
82
|
+
const message = error && typeof error.message === 'string' && error.message
|
|
83
|
+
? error.message
|
|
84
|
+
: 'Git 操作失败';
|
|
85
|
+
// git 的 stderr 只在服务端日志里有价值(英文、随版本变),不下发给手机。
|
|
86
|
+
sendJson(res, status, { ok: false, error: { code, message } });
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function text(value) {
|
|
92
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function query(req) {
|
|
96
|
+
return new URL(req.url || '/', 'http://x').searchParams;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function bodyJson(req) {
|
|
100
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
101
|
+
if (!body || typeof body !== 'object') throw fail('BAD_REQUEST', '需要 JSON 请求体');
|
|
102
|
+
return body;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 仓库内的相对路径:绝对路径、盘符、任何一段 `..` 一律拒掉。 */
|
|
106
|
+
function repoTargetPath(value) {
|
|
107
|
+
const raw = text(value);
|
|
108
|
+
if (!raw || raw.length > 4096) throw fail('PATH_OUTSIDE', '需要仓库内的相对路径');
|
|
109
|
+
if (isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw)) throw fail('PATH_OUTSIDE', '只接受相对仓库根的路径');
|
|
110
|
+
const segments = raw.split(/[\\/]/);
|
|
111
|
+
if (segments.some((segment) => segment === '..')) throw fail('PATH_OUTSIDE', '路径里不许出现 ..');
|
|
112
|
+
const normalized = segments.filter((segment) => segment && segment !== '.').join('/');
|
|
113
|
+
if (!normalized) throw fail('PATH_OUTSIDE', '需要仓库内的相对路径');
|
|
114
|
+
return normalized;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pathList(value) {
|
|
118
|
+
const list = Array.isArray(value) ? value : [value];
|
|
119
|
+
return list.map(repoTargetPath).filter(Boolean);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 把请求落到一个具体的仓库根上。
|
|
124
|
+
*
|
|
125
|
+
* `repo` 可以不给(默认主仓库);给了就必须是识别结果里的那几个根之一,而且必须是绝对路径 ——
|
|
126
|
+
* 相对路径会被插件自己的 cwd 解释,那是另一种「越界」。
|
|
127
|
+
*/
|
|
128
|
+
async function resolveTarget(ctx, params) {
|
|
129
|
+
const workspaceId = text(params.workspaceId);
|
|
130
|
+
if (!workspaceId) throw fail('BAD_REQUEST', '需要 workspaceId');
|
|
131
|
+
const listed = await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} });
|
|
132
|
+
const { value, error } = unwrap(listed);
|
|
133
|
+
if (error) throw fail('WORKSPACE_UNAVAILABLE', '取不到工作区清单,稍后再试');
|
|
134
|
+
const items = Array.isArray(value) ? value : (value && Array.isArray(value.items) ? value.items : []);
|
|
135
|
+
const item = items.find((entry) => entry && entry.workspaceId === workspaceId);
|
|
136
|
+
if (!item) throw fail('FORBIDDEN', '这个工作区不在这台机器上');
|
|
137
|
+
const projectPath = text(item.path);
|
|
138
|
+
if (!projectPath) throw fail('NOT_A_REPO', '这个工作区没有对应目录');
|
|
139
|
+
const marker = await describeProjectGit(projectPath);
|
|
140
|
+
if (!marker.isRepo) throw fail('NOT_A_REPO', '这个项目没有 Git 仓库');
|
|
141
|
+
const requested = text(params.repo);
|
|
142
|
+
if (requested && !isAbsolute(requested)) throw fail('FORBIDDEN', '仓库根要用绝对路径');
|
|
143
|
+
const repoRoot = requested ? resolve(requested) : marker.root;
|
|
144
|
+
if (!marker.repos.some((repo) => repo.root === repoRoot)) {
|
|
145
|
+
throw fail('FORBIDDEN', '这个仓库不在该项目的识别结果里');
|
|
146
|
+
}
|
|
147
|
+
return { projectPath, repoRoot };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** HEAD 是否存在:空仓库(还没有第一个提交)时很多命令的行为不一样。 */
|
|
151
|
+
async function hasHead(root) {
|
|
152
|
+
const result = await runGit(root, ['rev-parse', '--verify', '--quiet', 'HEAD'], { timeoutMs: 4000 });
|
|
153
|
+
return result.code === 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 把 git 的失败翻译成契约里的错误码(stderr 只用来分类,不下发)。 */
|
|
157
|
+
function classifyCommitFailure(stderr) {
|
|
158
|
+
const textValue = String(stderr || '');
|
|
159
|
+
if (/nothing to commit|no changes added/i.test(textValue)) return fail('NOTHING_STAGED', '没有可提交的暂存内容');
|
|
160
|
+
if (/hook/i.test(textValue)) return fail('HOOK_FAILED', '仓库的钩子拒绝了这次提交,回电脑上看一下');
|
|
161
|
+
if (/Please tell me who you are|unable to auto-detect email|empty ident/i.test(textValue)) {
|
|
162
|
+
return fail('IDENTITY_MISSING', '这台电脑上的 git 还没配好提交身份(user.name / user.email)');
|
|
163
|
+
}
|
|
164
|
+
return fail('GIT_FAILED', '提交失败,回电脑上看一下具体原因');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 把 push 的失败翻译成契约里的错误码。
|
|
169
|
+
*
|
|
170
|
+
* 用户这一端只可能做三件事:再推一次、回电脑上拉一下、回电脑上配凭据 —— 所以码就分这三类
|
|
171
|
+
* (外加远端地址 / 网络本身不通),不要按 git 的英文措辞一五一十地回给手机。
|
|
172
|
+
*/
|
|
173
|
+
function classifyPushFailure(stderr) {
|
|
174
|
+
const value = String(stderr || '');
|
|
175
|
+
if (/no upstream branch|has no upstream branch|set-upstream/i.test(value)) {
|
|
176
|
+
return fail('NO_UPSTREAM', '这条分支还没有上游分支:先在电脑上推一次建立跟踪');
|
|
177
|
+
}
|
|
178
|
+
if (/non-fast-forward|fetch first|\[rejected\]|\[remote rejected\]/i.test(value)) {
|
|
179
|
+
return fail('REJECTED', '远端有你还没拉取的提交,先在电脑上拉取再推');
|
|
180
|
+
}
|
|
181
|
+
// 地址 / 网络先判:这类 stderr 末尾也会跟一句 "Please make sure you have the correct access
|
|
182
|
+
// rights",先判凭据会把「地址写错」说成「没配凭据」。
|
|
183
|
+
if (/does not appear to be a git repository|could not resolve host|unable to access|connection (refused|timed out)|network is unreachable|no such remote/i.test(value)) {
|
|
184
|
+
return fail('REMOTE_UNAVAILABLE', '连不上远端仓库,检查一下网络或远端地址');
|
|
185
|
+
}
|
|
186
|
+
if (/authentication failed|could not read username|could not read password|permission denied \(publickey\)|terminal prompts disabled|invalid username or password/i.test(value)) {
|
|
187
|
+
return fail('AUTH_FAILED', '这台电脑上没有可用的远端凭据,回电脑上处理');
|
|
188
|
+
}
|
|
189
|
+
return fail('GIT_FAILED', '推送失败,回电脑上看一下具体原因');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── 只读 ────────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/** GET git/repo */
|
|
195
|
+
async function handleGitRepo(ctx, req, res) {
|
|
196
|
+
const params = query(req);
|
|
197
|
+
const target = await resolveTarget(ctx, {
|
|
198
|
+
workspaceId: params.get('workspaceId'),
|
|
199
|
+
repo: params.get('repo'),
|
|
200
|
+
});
|
|
201
|
+
sendJson(res, 200, { ok: true, ...(await read.repoOf(target.repoRoot)) });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** GET git/status:四个分组,带每个文件的增删与二进制标记。 */
|
|
205
|
+
async function handleGitStatus(ctx, req, res) {
|
|
206
|
+
const params = query(req);
|
|
207
|
+
const target = await resolveTarget(ctx, {
|
|
208
|
+
workspaceId: params.get('workspaceId'),
|
|
209
|
+
repo: params.get('repo'),
|
|
210
|
+
});
|
|
211
|
+
sendJson(res, 200, { ok: true, ...(await read.statusOf(target.repoRoot)) });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** GET git/diff?path=&staged=1&commit=<hash> */
|
|
215
|
+
async function handleGitDiff(ctx, req, res) {
|
|
216
|
+
const params = query(req);
|
|
217
|
+
const target = await resolveTarget(ctx, {
|
|
218
|
+
workspaceId: params.get('workspaceId'),
|
|
219
|
+
repo: params.get('repo'),
|
|
220
|
+
});
|
|
221
|
+
const path = repoTargetPath(params.get('path'));
|
|
222
|
+
const commit = text(params.get('commit'));
|
|
223
|
+
if (commit && !/^[0-9a-f]{4,64}$/i.test(commit)) throw fail('BAD_REQUEST', '提交号不合法');
|
|
224
|
+
const staged = params.get('staged') === '1' || params.get('staged') === 'true';
|
|
225
|
+
const diff = await read.diffOf(target.repoRoot, { path, staged, commit: commit || null });
|
|
226
|
+
sendJson(res, 200, { ok: true, path, ...diff });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** GET git/log?limit=&skip= */
|
|
230
|
+
async function handleGitLog(ctx, req, res) {
|
|
231
|
+
const params = query(req);
|
|
232
|
+
const target = await resolveTarget(ctx, {
|
|
233
|
+
workspaceId: params.get('workspaceId'),
|
|
234
|
+
repo: params.get('repo'),
|
|
235
|
+
});
|
|
236
|
+
const page = await read.logOf(target.repoRoot, {
|
|
237
|
+
limit: Number(params.get('limit')) || read.DEFAULT_LOG_LIMIT,
|
|
238
|
+
skip: Number(params.get('skip')) || 0,
|
|
239
|
+
});
|
|
240
|
+
sendJson(res, 200, { ok: true, ...page });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** GET git/show?hash= */
|
|
244
|
+
async function handleGitShow(ctx, req, res) {
|
|
245
|
+
const params = query(req);
|
|
246
|
+
const target = await resolveTarget(ctx, {
|
|
247
|
+
workspaceId: params.get('workspaceId'),
|
|
248
|
+
repo: params.get('repo'),
|
|
249
|
+
});
|
|
250
|
+
const hash = text(params.get('hash'));
|
|
251
|
+
if (!/^[0-9a-f]{4,64}$/i.test(hash)) throw fail('BAD_REQUEST', '提交号不合法');
|
|
252
|
+
sendJson(res, 200, { ok: true, ...(await read.showOf(target.repoRoot, hash)) });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** GET git/branches */
|
|
256
|
+
async function handleGitBranches(ctx, req, res) {
|
|
257
|
+
const params = query(req);
|
|
258
|
+
const target = await resolveTarget(ctx, {
|
|
259
|
+
workspaceId: params.get('workspaceId'),
|
|
260
|
+
repo: params.get('repo'),
|
|
261
|
+
});
|
|
262
|
+
sendJson(res, 200, { ok: true, ...(await read.branchesOf(target.repoRoot)) });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** GET git/overview?fresh=1 */
|
|
266
|
+
async function handleGitOverview(ctx, req, res) {
|
|
267
|
+
const params = query(req);
|
|
268
|
+
const target = await resolveTarget(ctx, {
|
|
269
|
+
workspaceId: params.get('workspaceId'),
|
|
270
|
+
repo: params.get('repo'),
|
|
271
|
+
});
|
|
272
|
+
const fresh = params.get('fresh') === '1';
|
|
273
|
+
sendJson(res, 200, { ok: true, ...(await read.overviewOf(target.repoRoot, { fresh })) });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── 写 ──────────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
/** POST git/stage { workspaceId, repo?, paths? | all? } */
|
|
279
|
+
async function handleGitStage(ctx, req, res) {
|
|
280
|
+
const body = await bodyJson(req);
|
|
281
|
+
const target = await resolveTarget(ctx, body);
|
|
282
|
+
const all = body.all === true;
|
|
283
|
+
const paths = all ? [] : pathList(body.paths ?? body.path);
|
|
284
|
+
if (!all && paths.length === 0) throw fail('BAD_REQUEST', '要说清楚暂存哪些文件,或者给 all');
|
|
285
|
+
const status = await withRepoLock(target.repoRoot, async () => {
|
|
286
|
+
const result = all
|
|
287
|
+
? await runGit(target.repoRoot, ['add', '-A'], { timeoutMs: 30000 })
|
|
288
|
+
: await runGit(target.repoRoot, ['add', '--', ...paths], { timeoutMs: 30000 });
|
|
289
|
+
if (result.error === 'GIT_UNAVAILABLE') throw fail('GIT_UNAVAILABLE', '这台电脑上没有可用的 git');
|
|
290
|
+
if (result.error === 'TIMEOUT') throw fail('TIMEOUT', '暂存超时了,稍后再试');
|
|
291
|
+
if (result.code !== 0) throw fail('GIT_FAILED', '暂存失败(可能是文件已被删除或路径不在仓库里)');
|
|
292
|
+
return read.statusOf(target.repoRoot);
|
|
293
|
+
});
|
|
294
|
+
sendJson(res, 200, { ok: true, status });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** POST git/unstage { workspaceId, repo?, paths? | all? } */
|
|
298
|
+
async function handleGitUnstage(ctx, req, res) {
|
|
299
|
+
const body = await bodyJson(req);
|
|
300
|
+
const target = await resolveTarget(ctx, body);
|
|
301
|
+
const all = body.all === true;
|
|
302
|
+
const paths = all ? [] : pathList(body.paths ?? body.path);
|
|
303
|
+
if (!all && paths.length === 0) throw fail('BAD_REQUEST', '要说清楚取消暂存哪些文件,或者给 all');
|
|
304
|
+
const status = await withRepoLock(target.repoRoot, async () => {
|
|
305
|
+
const head = await hasHead(target.repoRoot);
|
|
306
|
+
// 空仓库(没有第一个提交)没有 HEAD 可 reset,只能把索引里的条目移除。
|
|
307
|
+
const args = head
|
|
308
|
+
? (all ? ['reset', '-q'] : ['reset', '-q', 'HEAD', '--', ...paths])
|
|
309
|
+
: (all ? ['rm', '-q', '-r', '--cached', '--', '.'] : ['rm', '-q', '-r', '--cached', '--', ...paths]);
|
|
310
|
+
const result = await runGit(target.repoRoot, args, { timeoutMs: 30000 });
|
|
311
|
+
if (result.error === 'GIT_UNAVAILABLE') throw fail('GIT_UNAVAILABLE', '这台电脑上没有可用的 git');
|
|
312
|
+
if (result.error === 'TIMEOUT') throw fail('TIMEOUT', '取消暂存超时了,稍后再试');
|
|
313
|
+
if (result.code !== 0) throw fail('GIT_FAILED', '取消暂存失败');
|
|
314
|
+
return read.statusOf(target.repoRoot);
|
|
315
|
+
});
|
|
316
|
+
sendJson(res, 200, { ok: true, status });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** POST git/commit { workspaceId, repo?, message }:只提交已暂存那批。 */
|
|
320
|
+
async function handleGitCommit(ctx, req, res) {
|
|
321
|
+
const body = await bodyJson(req);
|
|
322
|
+
const target = await resolveTarget(ctx, body);
|
|
323
|
+
const message = text(body.message);
|
|
324
|
+
if (!message) throw fail('EMPTY_MESSAGE', '先写一句提交信息');
|
|
325
|
+
if (message.length > 8000) throw fail('BAD_REQUEST', '提交信息太长了');
|
|
326
|
+
const result = await withRepoLock(target.repoRoot, async () => {
|
|
327
|
+
const status = await read.statusOf(target.repoRoot);
|
|
328
|
+
// 索引里有未合并路径时真实 git 也会拒,提前说清楚,别等它报一段英文。
|
|
329
|
+
if (status.conflicted.length > 0) throw fail('CONFLICT', '还有没解决的冲突,回电脑上处理');
|
|
330
|
+
if (status.staged.length === 0) throw fail('NOTHING_STAGED', '没有已暂存的文件');
|
|
331
|
+
const committed = await runGit(target.repoRoot, ['commit', '-m', message], { timeoutMs: 30000 });
|
|
332
|
+
if (committed.error === 'GIT_UNAVAILABLE') throw fail('GIT_UNAVAILABLE', '这台电脑上没有可用的 git');
|
|
333
|
+
if (committed.error === 'TIMEOUT') throw fail('TIMEOUT', '提交超时了,稍后再试');
|
|
334
|
+
if (committed.code !== 0) throw classifyCommitFailure(committed.stderr);
|
|
335
|
+
const head = await runGit(target.repoRoot, ['rev-parse', 'HEAD'], { timeoutMs: 4000 });
|
|
336
|
+
const short = await runGit(target.repoRoot, ['rev-parse', '--short', 'HEAD'], { timeoutMs: 4000 });
|
|
337
|
+
return {
|
|
338
|
+
hash: (head.stdout || '').trim(),
|
|
339
|
+
shortHash: (short.stdout || '').trim(),
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
sendJson(res, 200, { ok: true, hash: result.hash, shortHash: result.shortHash });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** POST git/checkout { workspaceId, repo?, branch, create? }:切本地分支或新建一条。 */
|
|
346
|
+
async function handleGitCheckout(ctx, req, res) {
|
|
347
|
+
const body = await bodyJson(req);
|
|
348
|
+
const target = await resolveTarget(ctx, body);
|
|
349
|
+
const branch = text(body.branch);
|
|
350
|
+
if (!branch) throw fail('BAD_REQUEST', '需要分支名');
|
|
351
|
+
const create = body.create === true;
|
|
352
|
+
if (create) {
|
|
353
|
+
const checked = await runGit(target.repoRoot, ['check-ref-format', '--branch', branch], { timeoutMs: 4000 });
|
|
354
|
+
if (checked.code !== 0) throw fail('BAD_REQUEST', '分支名不合法');
|
|
355
|
+
}
|
|
356
|
+
const result = await withRepoLock(target.repoRoot, async () => {
|
|
357
|
+
const summary = await read.repoOf(target.repoRoot);
|
|
358
|
+
if (summary.detached) throw fail('DETACHED', '现在不在任何分支上,回电脑上处理');
|
|
359
|
+
const status = await read.statusOf(target.repoRoot);
|
|
360
|
+
// 有未提交的改动就拒绝:切分支时 git 可能把改动带过去,用户以为是另一份代码。
|
|
361
|
+
if (status.staged.length > 0 || status.unstaged.length > 0 || status.conflicted.length > 0) {
|
|
362
|
+
throw fail('DIRTY', '还有没提交的改动,先提交或回电脑上处理');
|
|
363
|
+
}
|
|
364
|
+
const switched = await runGit(target.repoRoot, create ? ['switch', '-c', branch] : ['switch', branch], { timeoutMs: 30000 });
|
|
365
|
+
if (switched.error === 'GIT_UNAVAILABLE') throw fail('GIT_UNAVAILABLE', '这台电脑上没有可用的 git');
|
|
366
|
+
if (switched.error === 'TIMEOUT') throw fail('TIMEOUT', '切分支超时了,稍后再试');
|
|
367
|
+
if (switched.code !== 0) {
|
|
368
|
+
const stderr = String(switched.stderr || '');
|
|
369
|
+
if (/did not match any|no such branch|invalid reference/i.test(stderr)) {
|
|
370
|
+
throw fail('NOT_FOUND', '没有这条分支');
|
|
371
|
+
}
|
|
372
|
+
if (/already exists/i.test(stderr)) throw fail('BAD_REQUEST', '这条分支已经存在了');
|
|
373
|
+
throw fail('GIT_FAILED', '切分支失败');
|
|
374
|
+
}
|
|
375
|
+
return read.repoOf(target.repoRoot);
|
|
376
|
+
});
|
|
377
|
+
sendJson(res, 200, { ok: true, branch: result.branch, detached: result.detached });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── AI 提交信息 ─────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
/** 喂给模型的最大 diff 字节数:太大既慢又贵,只留够判断「改了什么」的量。 */
|
|
383
|
+
const AI_DIFF_MAX_BYTES = 60 * 1024;
|
|
384
|
+
/** 单个文件节选的字节上限:几十个文件时按「改动量最大的几个」取样,每个封顶。 */
|
|
385
|
+
const AI_DIFF_PER_FILE_BYTES = 8 * 1024;
|
|
386
|
+
/** 取样几个文件的 diff(文件再多也只挑改动最大的这些,避免上下文被一两个大文件吃光)。 */
|
|
387
|
+
const AI_DIFF_SAMPLE_FILES = 8;
|
|
388
|
+
/** 给模型的总时限:等不到就报错,别让手机上一直转圈。 */
|
|
389
|
+
const AI_MESSAGE_TIMEOUT_MS = 30000;
|
|
390
|
+
/** 最终回给页面的提交信息长度上限(标题 + 几行要点)。 */
|
|
391
|
+
const AI_MESSAGE_MAX_CHARS = 600;
|
|
392
|
+
/** 改动到这个规模就值得多写几行要点,而不是硬压成一句。 */
|
|
393
|
+
const AI_BODY_MIN_FILES = 8;
|
|
394
|
+
|
|
395
|
+
const AI_SYSTEM_PROMPT = [
|
|
396
|
+
'你在给一个 Git 仓库写提交信息。第一行必须是 Conventional Commits 标题,格式 `type(scope): 说明`。',
|
|
397
|
+
'type 只能是 feat / fix / refactor / docs / test / chore / perf / style / build / ci 之一;scope 可以省略。',
|
|
398
|
+
'标题用中文、动词开头、说清这次最主要的改动,不超过 40 个汉字,不要句末句号。',
|
|
399
|
+
'输入的改动很少(一两个文件)时只输出那一行。',
|
|
400
|
+
'改动多、跨了几个模块时,从第二行起另起 2 到 4 条要点,每条以 `- ` 开头,形如「- 变更栏:提交与推送分成两步」。',
|
|
401
|
+
'要点说清了哪一块发生了什么,不要罗列文件名、不要抄路径;总行数不超过 6 行。',
|
|
402
|
+
'不要代码块、不要引号、不要编号、不要解释开头,也不要空行。',
|
|
403
|
+
].join('\n');
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 模型爱加的东西就地剥掉:代码块围栏、标题前的列表符号与引号、句末句号;
|
|
407
|
+
* 正文只留 `- ` 开头的要点,最多四条 —— 别的(解释、空行、文件名罗列)一律不要。
|
|
408
|
+
*/
|
|
409
|
+
function cleanCommitMessage(raw) {
|
|
410
|
+
const lines = String(raw || '')
|
|
411
|
+
.replace(/```[a-z]*/gi, '')
|
|
412
|
+
.split('\n')
|
|
413
|
+
.map((line) => line.trim())
|
|
414
|
+
.filter(Boolean);
|
|
415
|
+
const first = (lines[0] || '')
|
|
416
|
+
.replace(/^[-*\d.、)\s]+/, '')
|
|
417
|
+
.replace(/^["'`「『]+|["'`」』]+$/g, '')
|
|
418
|
+
.replace(/[。.]+$/, '')
|
|
419
|
+
.trim();
|
|
420
|
+
if (!first) return '';
|
|
421
|
+
const body = lines
|
|
422
|
+
.slice(1)
|
|
423
|
+
.map((line) => line.replace(/^[•·]\s*/, '- ').replace(/^(?!-)\s*/, '- '))
|
|
424
|
+
.map((line) => line.replace(/^-\s+/, '- ').replace(/[。.]+$/, '').trim())
|
|
425
|
+
.filter((line) => line.length > 2)
|
|
426
|
+
.slice(0, 4);
|
|
427
|
+
return [first, ...body].join('\n').slice(0, AI_MESSAGE_MAX_CHARS);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* POST git/commit-message { workspaceId, repo?, provider, model }
|
|
432
|
+
*
|
|
433
|
+
* 把「手上全部改动」(`git diff HEAD` + 未跟踪文件名;空仓库退化成 `--cached`)交给宿主那个
|
|
434
|
+
* `llm` 服务(跟渠道包、agent loop 同一条流式路径),让它写一行 Conventional Commits 标题填进
|
|
435
|
+
* 提交框 —— 只是**填框**,不提交、不推送。跟 VSCode 那颗一致:没暂存也要能写。
|
|
436
|
+
*
|
|
437
|
+
* 边界:模型要由手机指定(选哪个是用户的事,宿主不猜);工作区干净就直接拒(没有改动可总结,让模型凭空写一条比报错更糟)。(没有改动可总结,
|
|
438
|
+
* 让模型凭空写一条比报错更糟)。这里只读 `git diff --cached`,不写盘。
|
|
439
|
+
*/
|
|
440
|
+
async function handleGitCommitMessage(ctx, req, res) {
|
|
441
|
+
const body = await bodyJson(req);
|
|
442
|
+
const target = await resolveTarget(ctx, body);
|
|
443
|
+
const provider = text(body.provider);
|
|
444
|
+
const model = text(body.model);
|
|
445
|
+
if (!provider || !model) throw fail('BAD_REQUEST', '需要 provider 与 model');
|
|
446
|
+
|
|
447
|
+
const llm = typeof ctx.get === 'function' ? ctx.get('llm') : null;
|
|
448
|
+
if (!llm || typeof llm.stream !== 'function') throw fail('LLM_UNAVAILABLE', '这台电脑上的模型服务不可用');
|
|
449
|
+
|
|
450
|
+
// 「全部改动」而不是「只已暂存」:跟 VSCode 那颗一样,手上有未暂存的文件也要能写信息。
|
|
451
|
+
//
|
|
452
|
+
// 几十个文件时**不能只丢一份从头截断的 diff**:git 的 diff 按路径排序,截断之后模型只看得到
|
|
453
|
+
// 前面几个文件,写出来就永远是那一句。所以这里给两样东西:
|
|
454
|
+
// ① 全量清单(每个文件一行 + 增删行数,名字很便宜),让它知道这次有多大、动了哪些模块;
|
|
455
|
+
// ② 抽样 diff(按改动量从大到小取前几个,每个封顶),让它知道「具体怎么改的」。
|
|
456
|
+
const head = await hasHead(target.repoRoot);
|
|
457
|
+
const numstatArgs = head
|
|
458
|
+
? ['diff', 'HEAD', '--numstat', '--no-color']
|
|
459
|
+
: ['diff', '--cached', '--numstat', '--no-color'];
|
|
460
|
+
const [numstat, untracked] = await Promise.all([
|
|
461
|
+
runGit(target.repoRoot, numstatArgs, { timeoutMs: 15000, maxBytes: 8 * 1024 * 1024 }),
|
|
462
|
+
runGit(target.repoRoot, ['ls-files', '--others', '--exclude-standard'], { timeoutMs: 15000 }),
|
|
463
|
+
]);
|
|
464
|
+
const changed = String(numstat.stdout || '')
|
|
465
|
+
.split('\n')
|
|
466
|
+
.map((line) => line.split('\t'))
|
|
467
|
+
.filter((parts) => parts.length >= 3 && parts[2])
|
|
468
|
+
.map(([adds, dels, path]) => ({
|
|
469
|
+
path: path.trim(),
|
|
470
|
+
adds: Number(adds) || 0,
|
|
471
|
+
dels: Number(dels) || 0,
|
|
472
|
+
untracked: false,
|
|
473
|
+
}));
|
|
474
|
+
const newFiles = String(untracked.stdout || '')
|
|
475
|
+
.split('\n')
|
|
476
|
+
.map((line) => line.trim())
|
|
477
|
+
.filter(Boolean)
|
|
478
|
+
.map((path) => ({ path, adds: 0, dels: 0, untracked: true }));
|
|
479
|
+
const entries = [...changed, ...newFiles];
|
|
480
|
+
if (entries.length === 0) throw fail('NOTHING_TO_COMMIT', '工作区里没有可提交的改动,让 AI 写什么');
|
|
481
|
+
|
|
482
|
+
const totalAdds = entries.reduce((sum, file) => sum + file.adds, 0);
|
|
483
|
+
const totalDels = entries.reduce((sum, file) => sum + file.dels, 0);
|
|
484
|
+
const fileLines = entries
|
|
485
|
+
.slice(0, 200)
|
|
486
|
+
.map((file) => (file.untracked ? `新增文件\t${file.path}` : `修改文件\t${file.path}\t+${file.adds} -${file.dels}`));
|
|
487
|
+
const fileDigest = [
|
|
488
|
+
`这次一共 ${entries.length} 个文件,新增 ${totalAdds} 行、删除 ${totalDels} 行。`,
|
|
489
|
+
...fileLines,
|
|
490
|
+
...(entries.length > fileLines.length ? [`……还有 ${entries.length - fileLines.length} 个文件没列`] : []),
|
|
491
|
+
].join('\n');
|
|
492
|
+
|
|
493
|
+
// 抽样:改动量最大的几个文件的 diff(未跟踪文件没有 diff,跳过)。
|
|
494
|
+
const sampled = entries
|
|
495
|
+
.filter((file) => !file.untracked)
|
|
496
|
+
.sort((a, b) => (b.adds + b.dels) - (a.adds + a.dels))
|
|
497
|
+
.slice(0, AI_DIFF_SAMPLE_FILES);
|
|
498
|
+
const sampledDiffs = [];
|
|
499
|
+
for (const file of sampled) {
|
|
500
|
+
const args = head
|
|
501
|
+
? ['diff', 'HEAD', '--no-color', '--unified=2', '--', file.path]
|
|
502
|
+
: ['diff', '--cached', '--no-color', '--unified=2', '--', file.path];
|
|
503
|
+
// eslint-disable-next-line no-await-in-loop
|
|
504
|
+
const one = await runGit(target.repoRoot, args, { timeoutMs: 8000, maxBytes: 2 * 1024 * 1024 });
|
|
505
|
+
const body = String(one.stdout || '').slice(0, AI_DIFF_PER_FILE_BYTES);
|
|
506
|
+
if (body) sampledDiffs.push(body);
|
|
507
|
+
if (sampledDiffs.join('').length >= AI_DIFF_MAX_BYTES) break;
|
|
508
|
+
}
|
|
509
|
+
const diffText = sampledDiffs.join('\n').slice(0, AI_DIFF_MAX_BYTES);
|
|
510
|
+
|
|
511
|
+
const prompt = [
|
|
512
|
+
fileDigest,
|
|
513
|
+
'',
|
|
514
|
+
sampled.length > 0
|
|
515
|
+
? `下面是改动量最大的 ${sampled.length} 个文件的 diff(一共 ${entries.length} 个文件,这只是抽样):`
|
|
516
|
+
: '这些改动都是新文件,没有可看的 diff。',
|
|
517
|
+
diffText,
|
|
518
|
+
].join('\n');
|
|
519
|
+
|
|
520
|
+
const controller = new AbortController();
|
|
521
|
+
const timer = setTimeout(() => controller.abort(), AI_MESSAGE_TIMEOUT_MS);
|
|
522
|
+
let written = '';
|
|
523
|
+
let failure = null;
|
|
524
|
+
try {
|
|
525
|
+
for await (const chunk of llm.stream({
|
|
526
|
+
provider,
|
|
527
|
+
model,
|
|
528
|
+
system: AI_SYSTEM_PROMPT,
|
|
529
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
530
|
+
maxTokens: 200,
|
|
531
|
+
signal: controller.signal,
|
|
532
|
+
})) {
|
|
533
|
+
if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') written += chunk.text;
|
|
534
|
+
if (chunk && chunk.type === 'finish') {
|
|
535
|
+
// 协议层把失败摊在终止块上:`finish` 就是这次调用的最终结论。
|
|
536
|
+
if (chunk.reason && chunk.reason.kind === 'error') failure = chunk.reason.failure || {};
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
} catch (error) {
|
|
541
|
+
if (controller.signal.aborted) throw fail('TIMEOUT', '这个模型 30 秒没有回话,换一个模型试试');
|
|
542
|
+
throw fail('LLM_FAILED', String(error && error.message ? error.message : '模型调用失败').slice(0, 200));
|
|
543
|
+
} finally {
|
|
544
|
+
clearTimeout(timer);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (failure) {
|
|
548
|
+
throw fail('LLM_FAILED', String(failure.message || failure.code || '模型调用失败').slice(0, 200));
|
|
549
|
+
}
|
|
550
|
+
const message = cleanCommitMessage(written);
|
|
551
|
+
if (!message) throw fail('LLM_FAILED', '模型没写出提交信息,换一个模型试试');
|
|
552
|
+
sendJson(res, 200, { ok: true, message });
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* POST git/push { workspaceId, repo? }:把当前分支推到它的上游,永不强制。
|
|
557
|
+
*
|
|
558
|
+
* 不收分支名、不收远端名、不收任何开关 —— 能做的只有「把我已经提交的推上去」。没有上游就拒
|
|
559
|
+
* (让用户回电脑上推一次建立跟踪),落后远端也拒(拉取与合并留在电脑上),两条都不需要在这里
|
|
560
|
+
* 猜用户想要什么。
|
|
561
|
+
*/
|
|
562
|
+
async function handleGitPush(ctx, req, res) {
|
|
563
|
+
const body = await bodyJson(req);
|
|
564
|
+
const target = await resolveTarget(ctx, body);
|
|
565
|
+
const summary = await withRepoLock(target.repoRoot, async () => {
|
|
566
|
+
if (!(await hasHead(target.repoRoot))) throw fail('NO_UPSTREAM', '这个仓库还没有第一次提交');
|
|
567
|
+
const before = await read.repoOf(target.repoRoot);
|
|
568
|
+
if (before.detached) throw fail('DETACHED', '现在不在任何分支上,回电脑上处理');
|
|
569
|
+
if (!before.upstream) throw fail('NO_UPSTREAM', '这条分支还没有上游分支:先在电脑上推一次建立跟踪');
|
|
570
|
+
const pushed = await runGit(target.repoRoot, ['push'], { timeoutMs: 60000 });
|
|
571
|
+
if (pushed.error === 'GIT_UNAVAILABLE') throw fail('GIT_UNAVAILABLE', '这台电脑上没有可用的 git');
|
|
572
|
+
if (pushed.error === 'TIMEOUT') throw fail('TIMEOUT', '推送超时了,稍后再试');
|
|
573
|
+
// 退出码才是结论:「Everything up-to-date」也回 0,那不算失败。
|
|
574
|
+
if (pushed.code !== 0) throw classifyPushFailure(pushed.stderr);
|
|
575
|
+
return read.repoOf(target.repoRoot);
|
|
576
|
+
});
|
|
577
|
+
sendJson(res, 200, {
|
|
578
|
+
ok: true,
|
|
579
|
+
branch: summary.branch,
|
|
580
|
+
detached: summary.detached,
|
|
581
|
+
ahead: summary.ahead ?? 0,
|
|
582
|
+
behind: summary.behind ?? 0,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
module.exports = {
|
|
587
|
+
handleGitRepo: handle(handleGitRepo),
|
|
588
|
+
handleGitStatus: handle(handleGitStatus),
|
|
589
|
+
handleGitDiff: handle(handleGitDiff),
|
|
590
|
+
handleGitLog: handle(handleGitLog),
|
|
591
|
+
handleGitShow: handle(handleGitShow),
|
|
592
|
+
handleGitBranches: handle(handleGitBranches),
|
|
593
|
+
handleGitOverview: handle(handleGitOverview),
|
|
594
|
+
handleGitStage: handle(handleGitStage),
|
|
595
|
+
handleGitUnstage: handle(handleGitUnstage),
|
|
596
|
+
handleGitCommit: handle(handleGitCommit),
|
|
597
|
+
handleGitCheckout: handle(handleGitCheckout),
|
|
598
|
+
handleGitPush: handle(handleGitPush),
|
|
599
|
+
handleGitCommitMessage: handle(handleGitCommitMessage),
|
|
600
|
+
GitRequestError,
|
|
601
|
+
repoTargetPath,
|
|
602
|
+
resolveTarget,
|
|
603
|
+
};
|