dsh-git-ui 0.0.2 → 0.1.0
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 +27 -20
- package/README.zh.md +28 -21
- package/cordis.patch.yml +1 -1
- package/lib/client.js +36 -35
- package/lib/client.js.map +4 -4
- package/lib/host/actions.d.ts +22 -2
- package/lib/host/core.d.ts +7 -1
- package/lib/host/index.d.ts +12 -10
- package/lib/host/index.js +366 -21
- package/lib/host/index.js.map +4 -4
- package/lib/host/parser.d.ts +37 -1
- package/lib/host/queries.d.ts +17 -0
- package/lib/host/types.d.ts +131 -1
- package/package.json +1 -1
- package/src/client/GitCenter.tsx +1411 -148
- package/src/client/GitPill.tsx +280 -55
- package/src/client/changes-diff.ts +63 -0
- package/src/client/controller.ts +36 -1
- package/src/client/error-text.ts +21 -0
- package/src/client/file-tree.ts +101 -0
- package/src/client/git-graph.ts +188 -0
- package/src/client/icons.tsx +292 -0
- package/src/client/index.ts +2 -1
- package/src/client/locales.ts +124 -0
- package/src/client/popup-close.ts +19 -0
- package/src/client/remote.ts +83 -1
- package/src/client/select-menu.tsx +113 -0
- package/src/client/side-by-side.ts +150 -0
- package/src/client/styles.ts +1375 -86
- package/src/client/time-format.ts +32 -0
- package/src/host/actions.ts +66 -15
- package/src/host/core.ts +9 -2
- package/src/host/index.ts +21 -10
- package/src/host/parser.ts +155 -12
- package/src/host/queries.ts +289 -0
- package/src/host/types.ts +104 -0
package/lib/host/actions.d.ts
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
|
-
import { type GitStatusConfig, type SnapshotDeps } from './core.ts';
|
|
2
|
-
import type { GitActionResult, GitActionRequest } from './types.ts';
|
|
1
|
+
import { resolveWorkspace, type GitStatusConfig, type SnapshotDeps } from './core.ts';
|
|
2
|
+
import type { GitAction, GitActionResult, GitActionRequest, GitOperationErrorCode } from './types.ts';
|
|
3
|
+
/**
|
|
4
|
+
* A branch name is valid when it matches git's ref-name grammar at the level
|
|
5
|
+
* we care about: non-empty, ASCII ref chars only, no leading `-` (option
|
|
6
|
+
* injection guard, though argv never shells out), no `..` (path traversal of
|
|
7
|
+
* refs), no trailing `/`, and no double slashes.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isValidBranchName(name: string): boolean;
|
|
3
10
|
/**
|
|
4
11
|
* A path is safe when it is repo-relative and stays inside the work tree:
|
|
5
12
|
* reject absolute paths, drive letters / backslashes, and `..` escapes
|
|
6
13
|
* (checked via path resolution against the realpath'd root).
|
|
7
14
|
*/
|
|
8
15
|
export declare function isSafePath(path: string, root: string): boolean;
|
|
16
|
+
/** Map a snapshot-flow failure (which may carry git-unavailable) onto the operation error shape. */
|
|
17
|
+
export declare function operationError(failure: Extract<Awaited<ReturnType<typeof resolveWorkspace>>, {
|
|
18
|
+
ok: false;
|
|
19
|
+
}>['error']): GitActionResult & {
|
|
20
|
+
ok: false;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* 把 git 命令失败归类为可预期的业务错误(其余保持 git-error)。
|
|
24
|
+
* 切分支被工作区未提交变更阻止是最常见的可预期失败:git 输出
|
|
25
|
+
* "would be overwritten by checkout"(或中文本地化 "将被 checkout 覆盖"),
|
|
26
|
+
* 归一化为 local-changes-block,client 据此给友好提示 + 处理变更引导。
|
|
27
|
+
*/
|
|
28
|
+
export declare function classifyOperationError(kind: GitAction['kind'], message: string): GitOperationErrorCode;
|
|
9
29
|
/**
|
|
10
30
|
* Execute one management action against the session's repository and return
|
|
11
31
|
* the refreshed snapshot on success (the caller re-renders from it, so the
|
package/lib/host/core.d.ts
CHANGED
|
@@ -71,7 +71,13 @@ export declare function resolveWorkspace(deps: SnapshotDeps, sessionId: string):
|
|
|
71
71
|
* 1. `git rev-parse --show-toplevel` — repo detection (exit 128 → not-a-git-repo)
|
|
72
72
|
* 2. `git branch --show-current` — null when detached
|
|
73
73
|
* 3. `git rev-parse --short HEAD` — null + unborn when the repo has no commits
|
|
74
|
-
* 4. `git status --porcelain=v1 -z --branch`
|
|
74
|
+
* 4. `git status --porcelain=v1 -z --branch --untracked-files=all`
|
|
75
75
|
* 5. `git log -n 5 --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI`
|
|
76
|
+
*
|
|
77
|
+
* --untracked-files=all:git 默认 normal 模式会把整目录未跟踪折叠为单条
|
|
78
|
+
* `?? dir/`(尾斜杠)且不枚举其内部文件——隐藏目录(.agent/.tianqi 等)的
|
|
79
|
+
* 变更因此从不进入变更清单。`all` 强制逐文件枚举(与 IDEA / VSCode 一致),
|
|
80
|
+
* 内部文件得以展示;maxChanges 截断列表、maxStatusBytes spill 保计数精确,
|
|
81
|
+
* 超大未跟踪树(如未 gitignore 的构建产物)经此路径优雅降级。
|
|
76
82
|
*/
|
|
77
83
|
export declare function snapshotForSession(deps: SnapshotDeps, config: GitStatusConfig, sessionId: string): Promise<GitSnapshotResult>;
|
package/lib/host/index.d.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-git-ui host half: the `gitInfo` Remote service.
|
|
3
3
|
*
|
|
4
|
-
* Cordis shell only — every behavior lives in `core.ts`/`actions.ts
|
|
5
|
-
* injected structural faces, so tests never need a
|
|
6
|
-
* is a plugin in its own right (class form),
|
|
7
|
-
* with the package name; the gateway exposes
|
|
8
|
-
* `gitInfo/run`
|
|
9
|
-
* marker).
|
|
4
|
+
* Cordis shell only — every behavior lives in `core.ts`/`actions.ts`/
|
|
5
|
+
* `queries.ts` behind injected structural faces, so tests never need a
|
|
6
|
+
* cordis runtime. The class is a plugin in its own right (class form),
|
|
7
|
+
* mounted by the bundle patch row with the package name; the gateway exposes
|
|
8
|
+
* `gitInfo/snapshot`, `gitInfo/run` and `gitInfo/query` through SRC
|
|
9
|
+
* discovery (`typertRemote` binding + `@Remote` marker).
|
|
10
10
|
*/
|
|
11
11
|
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
12
12
|
import type { Context } from '@deepseek-ai/cordis';
|
|
13
|
-
import type { GitActionResult, GitActionRequest, GitSnapshotRequest, GitSnapshotResult } from './types.ts';
|
|
14
|
-
export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange, GitAction, GitActionResult, GitActionRequest } from './types.ts';
|
|
13
|
+
import type { GitActionResult, GitActionRequest, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult } from './types.ts';
|
|
14
|
+
export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange, GitAction, GitActionResult, GitActionRequest, GitQuery, GitQueryResult, GitQueryRequest, GitQueryResponse, GitBranch, GitFileStat, GitRef } from './types.ts';
|
|
15
15
|
export { normalizeConfig, DEFAULT_CONFIG } from './core.ts';
|
|
16
|
-
export { parseStatusOutput, parseLogOutput, parseBranchOutput } from './parser.ts';
|
|
17
|
-
export { isSafePath, runAction } from './actions.ts';
|
|
16
|
+
export { parseStatusOutput, parseLogOutput, parseBranchOutput, parseNameStatusOutput } from './parser.ts';
|
|
17
|
+
export { isSafePath, isValidBranchName, runAction } from './actions.ts';
|
|
18
|
+
export { runQuery } from './queries.ts';
|
|
18
19
|
/** The `gitInfo` service: `snapshot` (read) and `run` (management) endpoints. */
|
|
19
20
|
export declare class GitStatusService extends TypertRemoteService {
|
|
20
21
|
static inject: string[];
|
|
@@ -24,5 +25,6 @@ export declare class GitStatusService extends TypertRemoteService {
|
|
|
24
25
|
private deps;
|
|
25
26
|
snapshot(request: GitSnapshotRequest, signal?: AbortSignal): Promise<GitSnapshotResult>;
|
|
26
27
|
run(request: GitActionRequest, signal?: AbortSignal): Promise<GitActionResult>;
|
|
28
|
+
query(request: GitQueryRequest, signal?: AbortSignal): Promise<GitQueryResponse>;
|
|
27
29
|
}
|
|
28
30
|
export default GitStatusService;
|
package/lib/host/index.js
CHANGED
|
@@ -136,10 +136,8 @@ function parseStatusHeader(line) {
|
|
|
136
136
|
const branch = core.split("...", 1)[0] ?? core;
|
|
137
137
|
return { branch: branch === "" ? null : branch, unborn: false, ahead, behind };
|
|
138
138
|
}
|
|
139
|
-
function
|
|
140
|
-
|
|
141
|
-
if (x === "U" || y === "U" || x !== " " && y !== " ") return "conflicted";
|
|
142
|
-
switch (x) {
|
|
139
|
+
function singleStatus(code) {
|
|
140
|
+
switch (code) {
|
|
143
141
|
case "A":
|
|
144
142
|
return "added";
|
|
145
143
|
case "M":
|
|
@@ -156,6 +154,9 @@ function changeStatus(x, y) {
|
|
|
156
154
|
return "modified";
|
|
157
155
|
}
|
|
158
156
|
}
|
|
157
|
+
function isConflicted(x, y) {
|
|
158
|
+
return x === "U" || y === "U" || x === "A" && y === "A" || x === "D" && y === "D";
|
|
159
|
+
}
|
|
159
160
|
function parseStatusOutput(output, maxChanges) {
|
|
160
161
|
const raw = output.split(NUL);
|
|
161
162
|
const segments = raw[raw.length - 1] === "" ? raw.slice(0, -1) : raw;
|
|
@@ -165,6 +166,13 @@ function parseStatusOutput(output, maxChanges) {
|
|
|
165
166
|
let untracked = 0;
|
|
166
167
|
const changes = [];
|
|
167
168
|
let truncated = false;
|
|
169
|
+
const pushChange = (path, status, isStaged) => {
|
|
170
|
+
if (changes.length < maxChanges) {
|
|
171
|
+
changes.push({ path, status, staged: isStaged, isDirectory: path.endsWith("/") });
|
|
172
|
+
} else {
|
|
173
|
+
truncated = true;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
168
176
|
for (let index = 1; index < segments.length; index += 1) {
|
|
169
177
|
const entry = segments[index] ?? "";
|
|
170
178
|
const x = entry[0] ?? " ";
|
|
@@ -176,14 +184,19 @@ function parseStatusOutput(output, maxChanges) {
|
|
|
176
184
|
}
|
|
177
185
|
if (x === "?" && y === "?") {
|
|
178
186
|
untracked += 1;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (y !== " " && y !== "?") modified += 1;
|
|
187
|
+
pushChange(path, "untracked", false);
|
|
188
|
+
continue;
|
|
182
189
|
}
|
|
183
|
-
if (
|
|
184
|
-
|
|
190
|
+
if (x !== " " && x !== "?") staged += 1;
|
|
191
|
+
if (y !== " " && y !== "?") modified += 1;
|
|
192
|
+
if (isConflicted(x, y)) {
|
|
193
|
+
pushChange(path, "conflicted", true);
|
|
194
|
+
} else if (x !== " " && y !== " ") {
|
|
195
|
+
pushChange(path, singleStatus(x), true);
|
|
196
|
+
pushChange(path, singleStatus(y), false);
|
|
185
197
|
} else {
|
|
186
|
-
|
|
198
|
+
const isStaged = x !== " ";
|
|
199
|
+
pushChange(path, singleStatus(isStaged ? x : y), isStaged);
|
|
187
200
|
}
|
|
188
201
|
}
|
|
189
202
|
return {
|
|
@@ -214,6 +227,92 @@ function parseLogOutput(output) {
|
|
|
214
227
|
}
|
|
215
228
|
return commits;
|
|
216
229
|
}
|
|
230
|
+
function parseGraphLogOutput(output, remotes = []) {
|
|
231
|
+
const commits = [];
|
|
232
|
+
for (const line of output.split("\n")) {
|
|
233
|
+
if (line === "") continue;
|
|
234
|
+
const [hash, shortHash, subject, author, dateIso, parentField, decoField] = line.split(LOG_SEP);
|
|
235
|
+
if (hash === void 0 || hash === "") continue;
|
|
236
|
+
const parents = (parentField ?? "").split(" ").filter((p) => p !== "");
|
|
237
|
+
commits.push({
|
|
238
|
+
hash,
|
|
239
|
+
shortHash: shortHash ?? "",
|
|
240
|
+
subject: subject ?? "",
|
|
241
|
+
author: author ?? "",
|
|
242
|
+
dateIso: dateIso ?? "",
|
|
243
|
+
parents,
|
|
244
|
+
refs: parseDecorations(decoField ?? "", remotes)
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return commits;
|
|
248
|
+
}
|
|
249
|
+
function parseDecorations(decorations, remotes) {
|
|
250
|
+
const trimmed = decorations.trim();
|
|
251
|
+
if (trimmed === "") return [];
|
|
252
|
+
const refs = [];
|
|
253
|
+
for (const token of trimmed.split(", ")) {
|
|
254
|
+
if (token.startsWith("HEAD -> ")) {
|
|
255
|
+
refs.push({ kind: "branch", name: token.slice(8), head: true });
|
|
256
|
+
} else if (token.startsWith("tag: ")) {
|
|
257
|
+
refs.push({ kind: "tag", name: token.slice(5), head: false });
|
|
258
|
+
} else if (remotes.some((remote) => token === remote || token.startsWith(`${remote}/`))) {
|
|
259
|
+
refs.push({ kind: "remote", name: token, head: false });
|
|
260
|
+
} else {
|
|
261
|
+
refs.push({ kind: "branch", name: token, head: false });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return refs;
|
|
265
|
+
}
|
|
266
|
+
function parseShowMeta(output) {
|
|
267
|
+
const trimmed = output.trimEnd();
|
|
268
|
+
if (trimmed === "") return null;
|
|
269
|
+
const [hash, shortHash, subject, author, dateIso, ...bodyParts] = trimmed.split(LOG_SEP);
|
|
270
|
+
if (hash === void 0 || hash === "") return null;
|
|
271
|
+
return {
|
|
272
|
+
commit: {
|
|
273
|
+
hash,
|
|
274
|
+
shortHash: shortHash ?? "",
|
|
275
|
+
subject: subject ?? "",
|
|
276
|
+
author: author ?? "",
|
|
277
|
+
dateIso: dateIso ?? ""
|
|
278
|
+
},
|
|
279
|
+
body: bodyParts.join(LOG_SEP).trimEnd()
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function nameStatusCode(code) {
|
|
283
|
+
switch (code) {
|
|
284
|
+
case "A":
|
|
285
|
+
return "added";
|
|
286
|
+
case "D":
|
|
287
|
+
return "deleted";
|
|
288
|
+
case "R":
|
|
289
|
+
return "renamed";
|
|
290
|
+
case "T":
|
|
291
|
+
return "typechange";
|
|
292
|
+
case "U":
|
|
293
|
+
return "conflicted";
|
|
294
|
+
default:
|
|
295
|
+
return "modified";
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function parseNameStatusOutput(output) {
|
|
299
|
+
const raw = output.split(NUL);
|
|
300
|
+
const segments = raw[raw.length - 1] === "" ? raw.slice(0, -1) : raw;
|
|
301
|
+
const rows = [];
|
|
302
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
303
|
+
const entry = segments[i] ?? "";
|
|
304
|
+
if (entry === "") continue;
|
|
305
|
+
const code = entry[0] ?? " ";
|
|
306
|
+
if (code === "R" || code === "C") {
|
|
307
|
+
rows.push({ path: segments[i + 2] ?? "", status: nameStatusCode(code) });
|
|
308
|
+
i += 2;
|
|
309
|
+
} else {
|
|
310
|
+
rows.push({ path: segments[i + 1] ?? "", status: nameStatusCode(code) });
|
|
311
|
+
i += 1;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return rows;
|
|
315
|
+
}
|
|
217
316
|
function parseBranchOutput(output) {
|
|
218
317
|
const trimmed = output.trim();
|
|
219
318
|
return trimmed === "" ? null : trimmed;
|
|
@@ -296,7 +395,7 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
296
395
|
if ("failure" in headRun) return { ok: false, error: headRun.failure };
|
|
297
396
|
if (headRun.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
298
397
|
const head = headRun.run.exitCode === 0 ? headRun.run.stdout.trim() || null : null;
|
|
299
|
-
const status = await runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z", "--branch"], root, "status", deps.signal);
|
|
398
|
+
const status = await runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z", "--branch", "--untracked-files=all"], root, "status", deps.signal);
|
|
300
399
|
if ("failure" in status) return { ok: false, error: status.failure };
|
|
301
400
|
if (status.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
302
401
|
if (status.run.exitCode !== 0) {
|
|
@@ -334,15 +433,15 @@ import { resolve, sep } from "node:path";
|
|
|
334
433
|
function buildArgv(action, root) {
|
|
335
434
|
switch (action.kind) {
|
|
336
435
|
case "stage":
|
|
337
|
-
return withPaths(["git", "add", "--"], action.paths, root);
|
|
436
|
+
return withPaths([["git", "add", "--"]], action.paths, root);
|
|
338
437
|
case "stage-all":
|
|
339
438
|
return { argv: [["git", "add", "-A"]] };
|
|
340
439
|
case "unstage":
|
|
341
|
-
return withPaths(["git", "restore", "--staged", "--"], action.paths, root);
|
|
440
|
+
return withPaths([["git", "restore", "--staged", "--"]], action.paths, root);
|
|
342
441
|
case "unstage-all":
|
|
343
442
|
return { argv: [["git", "restore", "--staged", "--", "."]] };
|
|
344
443
|
case "discard":
|
|
345
|
-
return withPaths(["git", "restore", "--"], action.paths, root);
|
|
444
|
+
return withPaths([["git", "restore", "--"]], action.paths, root);
|
|
346
445
|
case "discard-all":
|
|
347
446
|
return { argv: [["git", "restore", "--staged", "--", "."], ["git", "restore", "--", "."]] };
|
|
348
447
|
case "commit": {
|
|
@@ -350,16 +449,30 @@ function buildArgv(action, root) {
|
|
|
350
449
|
if (action.paths === void 0 || action.paths.length === 0) {
|
|
351
450
|
return { argv: [["git", "commit", "-m", message]] };
|
|
352
451
|
}
|
|
353
|
-
return withPaths(["git", "commit", "-m", message, "--"], action.paths, root);
|
|
452
|
+
return withPaths([["git", "add", "--"], ["git", "commit", "-m", message, "--"]], action.paths, root);
|
|
453
|
+
}
|
|
454
|
+
case "branch-create": {
|
|
455
|
+
const from = action.from === void 0 || action.from === "" ? [] : [action.from];
|
|
456
|
+
return { argv: [["git", "branch", action.name, ...from]] };
|
|
354
457
|
}
|
|
458
|
+
case "branch-checkout":
|
|
459
|
+
return { argv: [["git", "checkout", action.name]] };
|
|
460
|
+
case "branch-delete":
|
|
461
|
+
return { argv: [["git", "branch", action.force === true ? "-D" : "-d", action.name]] };
|
|
462
|
+
case "fetch":
|
|
463
|
+
return { argv: [["git", "fetch", "--all", "--prune"]] };
|
|
355
464
|
}
|
|
356
465
|
}
|
|
357
|
-
function
|
|
466
|
+
function isValidBranchName(name) {
|
|
467
|
+
if (name === "" || name.startsWith("-") || name.includes("..") || name.endsWith("/") || name.includes("//")) return false;
|
|
468
|
+
return /^[A-Za-z0-9._/-]+$/.test(name);
|
|
469
|
+
}
|
|
470
|
+
function withPaths(prefixes, paths, root) {
|
|
358
471
|
if (paths.length === 0) return { error: "no paths given" };
|
|
359
472
|
for (const path of paths) {
|
|
360
473
|
if (!isSafePath(path, root)) return { error: `unsafe path: ${path}` };
|
|
361
474
|
}
|
|
362
|
-
return { argv: [
|
|
475
|
+
return { argv: prefixes.map((prefix) => [...prefix, ...paths]) };
|
|
363
476
|
}
|
|
364
477
|
function isSafePath(path, root) {
|
|
365
478
|
if (path === "") return false;
|
|
@@ -374,6 +487,12 @@ function operationError(failure) {
|
|
|
374
487
|
}
|
|
375
488
|
return { ok: false, error: failure };
|
|
376
489
|
}
|
|
490
|
+
function classifyOperationError(kind, message) {
|
|
491
|
+
if (kind === "branch-checkout" && /would be overwritten by checkout|将被 checkout 覆盖|有未跟踪工作区文件将会被 checkout 覆盖/i.test(message)) {
|
|
492
|
+
return "local-changes-block";
|
|
493
|
+
}
|
|
494
|
+
return "git-error";
|
|
495
|
+
}
|
|
377
496
|
async function runAction(deps, config, request) {
|
|
378
497
|
const workspace = await resolveWorkspace(deps, request.sessionId);
|
|
379
498
|
if (!workspace.ok) return operationError(workspace.error);
|
|
@@ -381,6 +500,13 @@ async function runAction(deps, config, request) {
|
|
|
381
500
|
if (request.action.kind === "commit" && request.action.message.trim() === "") {
|
|
382
501
|
return { ok: false, error: { code: "git-error", message: "commit message is empty" } };
|
|
383
502
|
}
|
|
503
|
+
const kind = request.action.kind;
|
|
504
|
+
if (kind === "branch-create" || kind === "branch-checkout" || kind === "branch-delete") {
|
|
505
|
+
const name = request.action.name;
|
|
506
|
+
if (!isValidBranchName(name)) {
|
|
507
|
+
return { ok: false, error: { code: "invalid-name", message: `invalid branch name: ${name}` } };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
384
510
|
const built = buildArgv(request.action, root);
|
|
385
511
|
if ("error" in built) return { ok: false, error: { code: "invalid-path", message: built.error } };
|
|
386
512
|
let lastStdout = "";
|
|
@@ -390,10 +516,11 @@ async function runAction(deps, config, request) {
|
|
|
390
516
|
if (outcome.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
391
517
|
if (outcome.run.exitCode !== 0) {
|
|
392
518
|
const message = outcome.run.stderr.trim() || outcome.run.stdout.trim();
|
|
519
|
+
const code = classifyOperationError(request.action.kind, message);
|
|
393
520
|
return {
|
|
394
521
|
ok: false,
|
|
395
522
|
error: {
|
|
396
|
-
code
|
|
523
|
+
code,
|
|
397
524
|
message: message !== "" ? message : `git ${request.action.kind} exited ${String(outcome.run.exitCode)}`
|
|
398
525
|
}
|
|
399
526
|
};
|
|
@@ -405,9 +532,216 @@ async function runAction(deps, config, request) {
|
|
|
405
532
|
return { ok: true, snapshot: snapshot.value, ...lastStdout === "" ? {} : { output: lastStdout } };
|
|
406
533
|
}
|
|
407
534
|
|
|
535
|
+
// src/host/queries.ts
|
|
536
|
+
var LOG_FORMAT = "%H%x1f%h%x1f%s%x1f%an%x1f%aI";
|
|
537
|
+
var GRAPH_FORMAT = "%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1f%P%x1f%D";
|
|
538
|
+
var MAX_HISTORY_LIMIT = 1e3;
|
|
539
|
+
function isValidRef(ref) {
|
|
540
|
+
return ref !== "" && !/\s/.test(ref);
|
|
541
|
+
}
|
|
542
|
+
async function runQuery(deps, config, request) {
|
|
543
|
+
void config;
|
|
544
|
+
const workspace = await resolveWorkspace(deps, request.sessionId);
|
|
545
|
+
if (!workspace.ok) return { ok: false, error: operationError(workspace.error).error };
|
|
546
|
+
const root = workspace.root;
|
|
547
|
+
const query = request.query;
|
|
548
|
+
switch (query.kind) {
|
|
549
|
+
case "history":
|
|
550
|
+
return historyQuery(deps, root, query);
|
|
551
|
+
case "diff":
|
|
552
|
+
return diffQuery(deps, root, query.path, query.base);
|
|
553
|
+
case "show":
|
|
554
|
+
return showQuery(deps, root, query.ref);
|
|
555
|
+
case "branches":
|
|
556
|
+
return branchesQuery(deps, root);
|
|
557
|
+
case "tags":
|
|
558
|
+
return tagsQuery(deps, root);
|
|
559
|
+
case "authors":
|
|
560
|
+
return authorsQuery(deps, root);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function historyQuery(deps, root, query) {
|
|
564
|
+
const safeLimit = Math.min(Math.max(Math.floor(query.limit), 0), MAX_HISTORY_LIMIT);
|
|
565
|
+
const safeSkip = Math.max(Math.floor(query.skip), 0);
|
|
566
|
+
if (query.ref !== void 0 && !isValidRef(query.ref)) {
|
|
567
|
+
return { ok: false, error: { code: "invalid-name", message: `invalid ref: ${query.ref}` } };
|
|
568
|
+
}
|
|
569
|
+
const search = query.search?.trim() ?? "";
|
|
570
|
+
const hexLike = /^[0-9a-f]{7,40}$/i.test(search);
|
|
571
|
+
const scope = hexLike ? [] : query.ref === void 0 ? ["--all"] : [query.ref];
|
|
572
|
+
const noWalk = hexLike ? ["--no-walk", search] : [];
|
|
573
|
+
const filters = [];
|
|
574
|
+
if (search !== "" && !hexLike) filters.push("--regexp-ignore-case", "--extended-regexp", `--grep=${search}`);
|
|
575
|
+
const author = query.author?.trim() ?? "";
|
|
576
|
+
if (author !== "") filters.push(`--author=${author}`);
|
|
577
|
+
const since = query.since?.trim() ?? "";
|
|
578
|
+
if (since !== "") filters.push(`--since=${since}`);
|
|
579
|
+
const log = await runCommand(
|
|
580
|
+
deps.run,
|
|
581
|
+
// -n/--skip 前置:git 的 `-n N` 出现在 `--no-walk` 之后会重置 no-walk
|
|
582
|
+
// (hexLike 会错误列出全部祖先),前置则 `-n 1000 --no-walk x` 恒返回单条。
|
|
583
|
+
["git", "log", ...filters, `--skip=${String(safeSkip)}`, "-n", String(safeLimit), ...noWalk, ...scope, `--format=${GRAPH_FORMAT}`],
|
|
584
|
+
root,
|
|
585
|
+
"log",
|
|
586
|
+
deps.signal
|
|
587
|
+
);
|
|
588
|
+
if ("failure" in log) return { ok: false, error: operationError(log.failure).error };
|
|
589
|
+
if (log.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
590
|
+
if (log.run.exitCode !== 0) {
|
|
591
|
+
if (log.run.stderr.includes("does not have any commits")) {
|
|
592
|
+
return { ok: true, value: { kind: "history", commits: [], total: 0 } };
|
|
593
|
+
}
|
|
594
|
+
if (hexLike && /unknown revision|bad revision|ambiguous/i.test(log.run.stderr)) {
|
|
595
|
+
return { ok: true, value: { kind: "history", commits: [], total: 0 } };
|
|
596
|
+
}
|
|
597
|
+
return gitError("log", log.run.stderr, log.run.stdout);
|
|
598
|
+
}
|
|
599
|
+
let total = 0;
|
|
600
|
+
const count = await runCommand(deps.run, ["git", "rev-list", "--count", ...noWalk, ...scope, ...filters], root, "rev-list", deps.signal);
|
|
601
|
+
if ("run" in count && count.run.exitCode === 0) {
|
|
602
|
+
const parsed = Number(count.run.stdout.trim());
|
|
603
|
+
if (Number.isFinite(parsed) && parsed >= 0) total = parsed;
|
|
604
|
+
}
|
|
605
|
+
let remotes = [];
|
|
606
|
+
const remoteRun = await runCommand(deps.run, ["git", "remote"], root, "remote", deps.signal);
|
|
607
|
+
if ("run" in remoteRun && remoteRun.run.exitCode === 0) {
|
|
608
|
+
remotes = remoteRun.run.stdout.split("\n").map((s) => s.trim()).filter((s) => s !== "");
|
|
609
|
+
}
|
|
610
|
+
return { ok: true, value: { kind: "history", commits: parseGraphLogOutput(log.run.stdout, remotes), total } };
|
|
611
|
+
}
|
|
612
|
+
async function diffQuery(deps, root, path, base) {
|
|
613
|
+
if (!isSafePath(path, root)) return { ok: false, error: { code: "invalid-path", message: `unsafe path: ${path}` } };
|
|
614
|
+
const argv = base === "staged" ? ["git", "diff", "--cached", "-U999999", "--", path] : ["git", "diff", "-U999999", "--", path];
|
|
615
|
+
const run = await runCommand(deps.run, argv, root, "diff", deps.signal);
|
|
616
|
+
if ("failure" in run) return { ok: false, error: operationError(run.failure).error };
|
|
617
|
+
if (run.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
618
|
+
if (run.run.exitCode !== 0) return gitError("diff", run.run.stderr, run.run.stdout);
|
|
619
|
+
if (run.run.stdout !== "" || base === "staged") {
|
|
620
|
+
return { ok: true, value: { kind: "diff", path, text: run.run.stdout } };
|
|
621
|
+
}
|
|
622
|
+
const ni = await runCommand(deps.run, ["git", "diff", "--no-index", "-U999999", "--", "/dev/null", path], root, "diff --no-index", deps.signal);
|
|
623
|
+
if ("failure" in ni) return { ok: false, error: operationError(ni.failure).error };
|
|
624
|
+
if (ni.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
625
|
+
if (ni.run.exitCode !== 0 && ni.run.exitCode !== 1) return gitError("diff", ni.run.stderr, ni.run.stdout);
|
|
626
|
+
return { ok: true, value: { kind: "diff", path, text: ni.run.stdout } };
|
|
627
|
+
}
|
|
628
|
+
async function showQuery(deps, root, ref) {
|
|
629
|
+
if (!isValidRef(ref)) return { ok: false, error: { code: "invalid-name", message: `invalid ref: ${ref}` } };
|
|
630
|
+
const meta = await runCommand(
|
|
631
|
+
deps.run,
|
|
632
|
+
["git", "show", "-s", `--format=${LOG_FORMAT}%x1f%b`, ref],
|
|
633
|
+
root,
|
|
634
|
+
"show",
|
|
635
|
+
deps.signal
|
|
636
|
+
);
|
|
637
|
+
if ("failure" in meta) return { ok: false, error: operationError(meta.failure).error };
|
|
638
|
+
if (meta.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
639
|
+
if (meta.run.exitCode !== 0) return gitError("show", meta.run.stderr, meta.run.stdout);
|
|
640
|
+
const stat2 = await runCommand(
|
|
641
|
+
deps.run,
|
|
642
|
+
["git", "-c", "core.quotePath=false", "show", "--format=", "--name-status", "-z", ref],
|
|
643
|
+
root,
|
|
644
|
+
"show --name-status",
|
|
645
|
+
deps.signal
|
|
646
|
+
);
|
|
647
|
+
if ("failure" in stat2) return { ok: false, error: operationError(stat2.failure).error };
|
|
648
|
+
if (stat2.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
649
|
+
if (stat2.run.exitCode !== 0) return gitError("show", stat2.run.stderr, stat2.run.stdout);
|
|
650
|
+
const parsed = parseShowMeta(meta.run.stdout);
|
|
651
|
+
return {
|
|
652
|
+
ok: true,
|
|
653
|
+
value: {
|
|
654
|
+
kind: "show",
|
|
655
|
+
ref,
|
|
656
|
+
commit: parsed?.commit ?? null,
|
|
657
|
+
body: parsed?.body ?? "",
|
|
658
|
+
stats: parseNameStatusOutput(stat2.run.stdout)
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
async function authorsQuery(deps, root) {
|
|
663
|
+
const run = await runCommand(deps.run, ["git", "log", "--all", "-n", "1000", "--format=%an"], root, "log authors", deps.signal);
|
|
664
|
+
if ("failure" in run) return { ok: false, error: operationError(run.failure).error };
|
|
665
|
+
if (run.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
666
|
+
if (run.run.exitCode !== 0) return { ok: true, value: { kind: "authors", authors: [] } };
|
|
667
|
+
const authors = [...new Set(run.run.stdout.split("\n").map((s) => s.trim()).filter((s) => s !== ""))].sort().slice(0, 100);
|
|
668
|
+
return { ok: true, value: { kind: "authors", authors } };
|
|
669
|
+
}
|
|
670
|
+
async function tagsQuery(deps, root) {
|
|
671
|
+
const FORMAT = "--format=%(refname:short)%09%(objectname:short)";
|
|
672
|
+
const run = await runCommand(deps.run, ["git", "tag", FORMAT], root, "tag", deps.signal);
|
|
673
|
+
if ("failure" in run) return { ok: false, error: operationError(run.failure).error };
|
|
674
|
+
if (run.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
675
|
+
if (run.run.exitCode !== 0) return gitError("tag", run.run.stderr, run.run.stdout);
|
|
676
|
+
return { ok: true, value: { kind: "tags", tags: parseBranchList(run.run.stdout) } };
|
|
677
|
+
}
|
|
678
|
+
async function branchesQuery(deps, root) {
|
|
679
|
+
const LOCAL_FORMAT = "--format=%(refname:short)%09%(objectname:short)%09%(upstream:short)%09%(upstream:track)";
|
|
680
|
+
const REMOTE_FORMAT = "--format=%(refname:short)%09%(objectname:short)";
|
|
681
|
+
const local = await runCommand(deps.run, ["git", "branch", LOCAL_FORMAT], root, "branch", deps.signal);
|
|
682
|
+
if ("failure" in local) return { ok: false, error: operationError(local.failure).error };
|
|
683
|
+
if (local.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
684
|
+
if (local.run.exitCode !== 0) return gitError("branch", local.run.stderr, local.run.stdout);
|
|
685
|
+
const remote = await runCommand(deps.run, ["git", "branch", "-r", REMOTE_FORMAT], root, "branch -r", deps.signal);
|
|
686
|
+
if ("failure" in remote) return { ok: false, error: operationError(remote.failure).error };
|
|
687
|
+
if (remote.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
688
|
+
if (remote.run.exitCode !== 0) return gitError("branch -r", remote.run.stderr, remote.run.stdout);
|
|
689
|
+
const current = await runCommand(deps.run, ["git", "branch", "--show-current"], root, "branch", deps.signal);
|
|
690
|
+
const currentName = "run" in current && current.run.exitCode === 0 ? parseBranchOutput(current.run.stdout) : null;
|
|
691
|
+
let defaultBranch = null;
|
|
692
|
+
const def = await runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], root, "symbolic-ref", deps.signal);
|
|
693
|
+
if ("run" in def && def.run.exitCode === 0) {
|
|
694
|
+
const value = def.run.stdout.trim();
|
|
695
|
+
const slash = value.indexOf("/");
|
|
696
|
+
defaultBranch = value === "" ? null : slash === -1 ? value : value.slice(slash + 1);
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
ok: true,
|
|
700
|
+
value: {
|
|
701
|
+
kind: "branches",
|
|
702
|
+
current: currentName,
|
|
703
|
+
defaultBranch,
|
|
704
|
+
local: parseBranchList(local.run.stdout),
|
|
705
|
+
remote: parseBranchList(remote.run.stdout).filter((branch) => !branch.name.endsWith("/HEAD"))
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function parseBranchList(output) {
|
|
710
|
+
const branches = [];
|
|
711
|
+
for (const line of output.split("\n")) {
|
|
712
|
+
if (line === "") continue;
|
|
713
|
+
const parts = line.split(" ");
|
|
714
|
+
const name = parts[0];
|
|
715
|
+
const hash = parts[1];
|
|
716
|
+
if (name === void 0 || name === "") continue;
|
|
717
|
+
const track = parts[3] ?? "";
|
|
718
|
+
const aheadMatch = /ahead (\d+)/.exec(track);
|
|
719
|
+
const behindMatch = /behind (\d+)/.exec(track);
|
|
720
|
+
const ahead = aheadMatch ? Number(aheadMatch[1]) : 0;
|
|
721
|
+
const behind = behindMatch ? Number(behindMatch[1]) : 0;
|
|
722
|
+
branches.push({
|
|
723
|
+
name,
|
|
724
|
+
shortHash: hash === void 0 || hash === "" ? null : hash,
|
|
725
|
+
...ahead > 0 ? { ahead } : {},
|
|
726
|
+
...behind > 0 ? { behind } : {}
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
return branches;
|
|
730
|
+
}
|
|
731
|
+
function gitError(label, stderr, stdout) {
|
|
732
|
+
const message = stderr.trim() || stdout.trim();
|
|
733
|
+
return {
|
|
734
|
+
ok: false,
|
|
735
|
+
error: {
|
|
736
|
+
code: "git-error",
|
|
737
|
+
message: message !== "" ? message : `git ${label} failed`
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
408
742
|
// src/host/index.ts
|
|
409
|
-
var _run_dec, _snapshot_dec, _a, _init;
|
|
410
|
-
var GitStatusService = class extends (_a = TypertRemoteService, _snapshot_dec = [Remote("snapshot")], _run_dec = [Remote("run")], _a) {
|
|
743
|
+
var _query_dec, _run_dec, _snapshot_dec, _a, _init;
|
|
744
|
+
var GitStatusService = class extends (_a = TypertRemoteService, _snapshot_dec = [Remote("snapshot")], _run_dec = [Remote("run")], _query_dec = [Remote("query")], _a) {
|
|
411
745
|
constructor(ctx, config) {
|
|
412
746
|
super(ctx, "gitInfo");
|
|
413
747
|
__runInitializers(_init, 5, this);
|
|
@@ -455,10 +789,18 @@ var GitStatusService = class extends (_a = TypertRemoteService, _snapshot_dec =
|
|
|
455
789
|
}
|
|
456
790
|
return runAction(adapted.deps, this.config, request);
|
|
457
791
|
}
|
|
792
|
+
async query(request, signal) {
|
|
793
|
+
const adapted = this.deps(signal);
|
|
794
|
+
if ("failure" in adapted) {
|
|
795
|
+
return { ok: false, error: { code: "git-error", message: adapted.failure.detail } };
|
|
796
|
+
}
|
|
797
|
+
return runQuery(adapted.deps, this.config, request);
|
|
798
|
+
}
|
|
458
799
|
};
|
|
459
800
|
_init = __decoratorStart(_a);
|
|
460
801
|
__decorateElement(_init, 1, "snapshot", _snapshot_dec, GitStatusService);
|
|
461
802
|
__decorateElement(_init, 1, "run", _run_dec, GitStatusService);
|
|
803
|
+
__decorateElement(_init, 1, "query", _query_dec, GitStatusService);
|
|
462
804
|
__decoratorMetadata(_init, GitStatusService);
|
|
463
805
|
__publicField(GitStatusService, "inject", ["subprocess", "sessions", "sessionPersistence"]);
|
|
464
806
|
var index_default = GitStatusService;
|
|
@@ -467,10 +809,13 @@ export {
|
|
|
467
809
|
GitStatusService,
|
|
468
810
|
index_default as default,
|
|
469
811
|
isSafePath,
|
|
812
|
+
isValidBranchName,
|
|
470
813
|
normalizeConfig,
|
|
471
814
|
parseBranchOutput,
|
|
472
815
|
parseLogOutput,
|
|
816
|
+
parseNameStatusOutput,
|
|
473
817
|
parseStatusOutput,
|
|
474
|
-
runAction
|
|
818
|
+
runAction,
|
|
819
|
+
runQuery
|
|
475
820
|
};
|
|
476
821
|
//# sourceMappingURL=index.js.map
|