@xbzbing/dsh-git-panel 1.5.2 → 1.5.4
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_EN.md → README.en.md} +2 -2
- package/README.md +3 -3
- package/lib/client.js +8 -7
- package/lib/host/actions.d.ts +3 -3
- package/lib/host/actions.js +230 -0
- package/lib/host/core.d.ts +3 -3
- package/lib/host/core.js +300 -0
- package/lib/host/git.js +65 -0
- package/lib/host/index.d.ts +10 -10
- package/lib/host/index.js +27 -1680
- package/lib/host/llm-face.js +0 -0
- package/lib/host/parser.d.ts +1 -1
- package/lib/host/parser.js +213 -0
- package/lib/host/queries.d.ts +2 -2
- package/lib/host/queries.js +461 -0
- package/lib/host/remote.d.ts +1 -1
- package/lib/host/remote.js +48 -0
- package/lib/host/suggest.d.ts +2 -2
- package/lib/host/suggest.js +217 -0
- package/lib/host/types.js +22 -0
- package/lib/host/validate.js +26 -0
- package/lib/host/version.js +130 -0
- package/package.json +10 -3
package/lib/host/actions.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { SnapshotDeps, GitPanelConfig } from './core.
|
|
2
|
-
import { isSafePath } from './validate.
|
|
3
|
-
import type { GitAction, GitActionRequest, GitActionResult, GitErrorCode } from './types.
|
|
1
|
+
import type { SnapshotDeps, GitPanelConfig } from './core.js';
|
|
2
|
+
import { isSafePath } from './validate.js';
|
|
3
|
+
import type { GitAction, GitActionRequest, GitActionResult, GitErrorCode } from './types.js';
|
|
4
4
|
export { isSafePath };
|
|
5
5
|
interface CommandPlan {
|
|
6
6
|
readonly argv: readonly (readonly string[])[];
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { join, sep } from "node:path";
|
|
2
|
+
import { mapWorkspaceFailure, resolveWorkspace, runCommand, snapshotForSession } from "./core.js";
|
|
3
|
+
import { isSafeBranchName, isSafePath, isSafeRemoteName, isSafeRev } from "./validate.js";
|
|
4
|
+
function withPaths(prefixes, paths) {
|
|
5
|
+
if (paths.length === 0) return { error: "invalid-path", message: "no paths given" };
|
|
6
|
+
for (const path of paths) {
|
|
7
|
+
if (!isSafePath(path)) return { error: "invalid-path", message: `unsafe path: ${path}` };
|
|
8
|
+
}
|
|
9
|
+
return { argv: prefixes.map((prefix) => [...prefix, ...paths]) };
|
|
10
|
+
}
|
|
11
|
+
function planAction(action, unborn) {
|
|
12
|
+
switch (action.kind) {
|
|
13
|
+
case "stage":
|
|
14
|
+
return withPaths([["git", "add", "--"]], action.paths);
|
|
15
|
+
case "stage-all":
|
|
16
|
+
return { argv: [["git", "add", "-A"]] };
|
|
17
|
+
case "unstage":
|
|
18
|
+
return unborn ? withPaths([["git", "rm", "--cached", "-r", "--"]], action.paths) : withPaths([["git", "restore", "--staged", "--"]], action.paths);
|
|
19
|
+
case "unstage-all":
|
|
20
|
+
return unborn ? { argv: [["git", "rm", "--cached", "-r", "--", "."]] } : { argv: [["git", "restore", "--staged", "--", "."]] };
|
|
21
|
+
case "discard":
|
|
22
|
+
return withPaths([["git", "restore", "--"]], action.paths);
|
|
23
|
+
case "commit": {
|
|
24
|
+
const message = action.message.trim();
|
|
25
|
+
const amend = action.amend === true;
|
|
26
|
+
if (message === "" && !amend) return { error: "empty-message" };
|
|
27
|
+
const amendFlag = amend ? ["--amend"] : [];
|
|
28
|
+
const msgArgs = message === "" ? ["--no-edit"] : ["-m", message];
|
|
29
|
+
if (action.paths === void 0 || action.paths.length === 0) {
|
|
30
|
+
return { argv: [["git", "commit", ...amendFlag, ...msgArgs]] };
|
|
31
|
+
}
|
|
32
|
+
const staged = withPaths([["git", "add", "--"]], action.paths);
|
|
33
|
+
if ("error" in staged) return staged;
|
|
34
|
+
const commitCmd = ["git", "commit", ...amendFlag, ...msgArgs, "--", ...action.paths];
|
|
35
|
+
return { argv: [...staged.argv, commitCmd] };
|
|
36
|
+
}
|
|
37
|
+
case "branch-checkout":
|
|
38
|
+
if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe branch name: ${action.name}` };
|
|
39
|
+
return { argv: [["git", "checkout", "--end-of-options", action.name]] };
|
|
40
|
+
case "fetch":
|
|
41
|
+
return { argv: [["git", "fetch", "--all", "--prune"]] };
|
|
42
|
+
case "pull-ff":
|
|
43
|
+
return { argv: [["git", "pull", "--ff-only"]] };
|
|
44
|
+
case "push":
|
|
45
|
+
return { argv: [["git", "push"]] };
|
|
46
|
+
case "publish":
|
|
47
|
+
if (!isSafeRemoteName(action.remote)) return { error: "invalid-name", message: `unsafe remote name: ${action.remote}` };
|
|
48
|
+
return { argv: [["git", "push", "-u", action.remote, "HEAD"]] };
|
|
49
|
+
case "tag-create": {
|
|
50
|
+
if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe tag name: ${action.name}` };
|
|
51
|
+
if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
|
|
52
|
+
const msg = action.message?.trim() ?? "";
|
|
53
|
+
const flags = msg === "" ? [] : ["-a", "-m", msg];
|
|
54
|
+
return { argv: [["git", "tag", ...flags, "--end-of-options", action.name, action.commit]] };
|
|
55
|
+
}
|
|
56
|
+
case "tag-delete":
|
|
57
|
+
if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe tag name: ${action.name}` };
|
|
58
|
+
return { argv: [["git", "tag", "-d", "--end-of-options", action.name]] };
|
|
59
|
+
case "stash-push": {
|
|
60
|
+
const msg = action.message?.trim() ?? "";
|
|
61
|
+
return { argv: [msg === "" ? ["git", "stash", "push"] : ["git", "stash", "push", "-m", msg]] };
|
|
62
|
+
}
|
|
63
|
+
case "stash-apply":
|
|
64
|
+
case "stash-pop":
|
|
65
|
+
case "stash-drop": {
|
|
66
|
+
if (!Number.isInteger(action.index) || action.index < 0) {
|
|
67
|
+
return { error: "invalid-index", message: `invalid stash index: ${action.index}` };
|
|
68
|
+
}
|
|
69
|
+
const verb = action.kind === "stash-apply" ? "apply" : action.kind === "stash-pop" ? "pop" : "drop";
|
|
70
|
+
return { argv: [["git", "stash", verb, "--end-of-options", `stash@{${action.index}}`]] };
|
|
71
|
+
}
|
|
72
|
+
case "revert": {
|
|
73
|
+
if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
|
|
74
|
+
return { argv: [["git", "revert", "--no-edit", "--end-of-options", action.commit]] };
|
|
75
|
+
}
|
|
76
|
+
case "reset": {
|
|
77
|
+
if (!isSafeRev(action.commit)) return { error: "invalid-name", message: `unsafe commit: ${action.commit}` };
|
|
78
|
+
if (action.mode !== "soft" && action.mode !== "mixed" && action.mode !== "hard") {
|
|
79
|
+
return { error: "invalid-name", message: `invalid reset mode: ${String(action.mode)}` };
|
|
80
|
+
}
|
|
81
|
+
return { argv: [["git", "reset", `--${action.mode}`, "--end-of-options", action.commit]] };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function isIndexBusy(outcome) {
|
|
86
|
+
if (!("run" in outcome) || outcome.run.exitCode === 0) return false;
|
|
87
|
+
return /index\.lock|Unable to create.*index|another git process/i.test(outcome.run.stderr + outcome.run.stdout);
|
|
88
|
+
}
|
|
89
|
+
function scrubCredentials(text) {
|
|
90
|
+
return text.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, "$1");
|
|
91
|
+
}
|
|
92
|
+
function classifyActionFailure(stdout, stderr, exitCode) {
|
|
93
|
+
const combined = stderr + stdout;
|
|
94
|
+
const err = scrubCredentials(stderr.trim());
|
|
95
|
+
if (/CONFLICT|Merge conflict|needs merge|could not restore untracked/i.test(combined)) {
|
|
96
|
+
return { code: "conflict", message: err || "merge conflict" };
|
|
97
|
+
}
|
|
98
|
+
if (/index\.lock|Unable to create.*index|another git process/i.test(combined)) {
|
|
99
|
+
return { code: "index-busy", message: err };
|
|
100
|
+
}
|
|
101
|
+
if (/nothing to commit|no changes added/i.test(combined)) {
|
|
102
|
+
return { code: "git-error", message: err || "nothing to commit" };
|
|
103
|
+
}
|
|
104
|
+
if (/No such ref|not a valid reference|is not a stash|no tag|tag .* not found|unknown revision|bad revision|Could not parse object|ambiguous argument/i.test(combined)) {
|
|
105
|
+
return { code: "not-found", message: err || "not found" };
|
|
106
|
+
}
|
|
107
|
+
if (/Authentication failed|could not read (Username|Password)|terminal prompts disabled|Permission denied \(publickey\)|Could not read from remote repository/i.test(combined)) {
|
|
108
|
+
return { code: "auth-failed", message: err || "authentication failed" };
|
|
109
|
+
}
|
|
110
|
+
if (/\[rejected\]|Updates were rejected|failed to push some refs|fetch first|tip of your current branch is behind/i.test(combined)) {
|
|
111
|
+
return { code: "push-rejected", message: err || "push rejected (remote has new commits; fetch first)" };
|
|
112
|
+
}
|
|
113
|
+
if (/Not possible to fast-forward|cannot fast-forward|not a fast-forward|Need to specify how to reconcile divergent branches/i.test(combined)) {
|
|
114
|
+
return { code: "not-ff", message: err || "cannot fast-forward (branches diverged)" };
|
|
115
|
+
}
|
|
116
|
+
if (/would be overwritten by (checkout|merge)|local changes|overwritten by merge|Your local changes/i.test(stderr)) {
|
|
117
|
+
return { code: "local-changes-block", message: err };
|
|
118
|
+
}
|
|
119
|
+
if (/is a merge but no -m|mainline parent/i.test(combined)) {
|
|
120
|
+
return { code: "revert-merge", message: err || "cannot revert a merge commit from the panel" };
|
|
121
|
+
}
|
|
122
|
+
return { code: "git-error", message: err || `git exited ${exitCode}` };
|
|
123
|
+
}
|
|
124
|
+
async function runAction(deps, config, request) {
|
|
125
|
+
const workspace = await resolveWorkspace(deps, request.sessionId);
|
|
126
|
+
if (!workspace.ok) return { ok: false, error: mapWorkspaceFailure(workspace.failure) };
|
|
127
|
+
const root = workspace.root;
|
|
128
|
+
const headProbe = await runCommand(deps.run, ["git", "rev-parse", "--verify", "HEAD"], root, "head-probe", deps.signal);
|
|
129
|
+
const unborn = !("run" in headProbe) || headProbe.run.exitCode !== 0;
|
|
130
|
+
const act = request.action;
|
|
131
|
+
if (act.kind === "stash-apply" || act.kind === "stash-pop" || act.kind === "stash-drop") {
|
|
132
|
+
if (!Number.isInteger(act.index) || act.index < 0) {
|
|
133
|
+
return { ok: false, error: { code: "invalid-index", message: `invalid stash index: ${act.index}` } };
|
|
134
|
+
}
|
|
135
|
+
const probe = await runCommand(deps.run, ["git", "rev-parse", "--verify", "--quiet", "--end-of-options", `stash@{${act.index}}`], root, "stash-verify", deps.signal);
|
|
136
|
+
const sha = "run" in probe && probe.run.exitCode === 0 ? probe.run.stdout.trim() : "";
|
|
137
|
+
if (sha === "" || sha !== act.sha) {
|
|
138
|
+
return { ok: false, error: { code: "not-found", message: "stash entry changed; refresh and retry" } };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (request.action.kind === "discard") {
|
|
142
|
+
const removed = await discardUntracked(deps, config, root, request.sessionId, request.action.paths);
|
|
143
|
+
if (removed !== null) {
|
|
144
|
+
if (!removed.ok) return removed.result;
|
|
145
|
+
if (removed.remainingTracked.length === 0) {
|
|
146
|
+
const snapshot2 = await snapshotForSession(deps, config, request.sessionId);
|
|
147
|
+
if (!snapshot2.ok) return { ok: false, error: { code: "git-error", message: "snapshot after action failed" } };
|
|
148
|
+
return { ok: true, snapshot: snapshot2.value, output: "" };
|
|
149
|
+
}
|
|
150
|
+
request = { ...request, action: { kind: "discard", paths: removed.remainingTracked } };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const plan = planAction(request.action, unborn);
|
|
154
|
+
if ("error" in plan) return { ok: false, error: { code: plan.error, ...plan.message ? { message: plan.message } : {} } };
|
|
155
|
+
let lastOutput = "";
|
|
156
|
+
for (let step = 0; step < plan.argv.length; step += 1) {
|
|
157
|
+
const argv = plan.argv[step];
|
|
158
|
+
let outcome = await runCommand(deps.run, argv, root, "action", deps.signal);
|
|
159
|
+
if (isIndexBusy(outcome)) {
|
|
160
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
161
|
+
outcome = await runCommand(deps.run, argv, root, "action-retry", deps.signal);
|
|
162
|
+
}
|
|
163
|
+
const where = plan.argv.length > 1 ? ` (step ${step + 1}/${plan.argv.length}: ${argv.join(" ")})` : "";
|
|
164
|
+
if ("failure" in outcome) {
|
|
165
|
+
const message = outcome.failure instanceof Error ? outcome.failure.message : String(outcome.failure);
|
|
166
|
+
return { ok: false, error: { code: "git-unavailable", message: message + where } };
|
|
167
|
+
}
|
|
168
|
+
if (outcome.run.cancelled) return { ok: false, error: { code: "cancelled" } };
|
|
169
|
+
if (outcome.run.timedOut) return { ok: false, error: { code: "timeout" } };
|
|
170
|
+
lastOutput = outcome.run.stdout || outcome.run.stderr;
|
|
171
|
+
if (outcome.run.exitCode !== 0) {
|
|
172
|
+
const failure = classifyActionFailure(outcome.run.stdout, outcome.run.stderr, outcome.run.exitCode ?? -1);
|
|
173
|
+
if (request.action.kind === "revert" && failure.code === "conflict") {
|
|
174
|
+
let abort = await runCommand(deps.run, ["git", "revert", "--abort"], root, "revert-abort", deps.signal);
|
|
175
|
+
if (isIndexBusy(abort)) {
|
|
176
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
177
|
+
abort = await runCommand(deps.run, ["git", "revert", "--abort"], root, "revert-abort-retry", deps.signal);
|
|
178
|
+
}
|
|
179
|
+
const aborted = "run" in abort && abort.run.exitCode === 0;
|
|
180
|
+
return aborted ? { ok: false, error: { code: "revert-conflict", message: failure.message + where } } : { ok: false, error: { code: "revert-stuck", message: failure.message + where } };
|
|
181
|
+
}
|
|
182
|
+
return { ok: false, error: { code: failure.code, message: failure.message + where } };
|
|
183
|
+
}
|
|
184
|
+
if (request.action.kind === "stash-push" && /No local changes to save/i.test(outcome.run.stdout + outcome.run.stderr)) {
|
|
185
|
+
return { ok: false, error: { code: "git-error", message: (outcome.run.stdout.trim() || "No local changes to save") + where } };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const snapshot = await snapshotForSession(deps, config, request.sessionId);
|
|
189
|
+
if (!snapshot.ok) {
|
|
190
|
+
return { ok: false, error: { code: "git-error", message: "snapshot after action failed" } };
|
|
191
|
+
}
|
|
192
|
+
return { ok: true, snapshot: snapshot.value, output: lastOutput.trim() };
|
|
193
|
+
}
|
|
194
|
+
async function discardUntracked(deps, config, root, sessionId, paths) {
|
|
195
|
+
const snap = await snapshotForSession(deps, config, sessionId);
|
|
196
|
+
if (!snap.ok) return null;
|
|
197
|
+
const untrackedSet = new Set(snap.value.changes.filter((c) => c.status === "untracked").map((c) => c.path));
|
|
198
|
+
const untracked = paths.filter((p) => untrackedSet.has(p));
|
|
199
|
+
if (untracked.length === 0) return null;
|
|
200
|
+
const tracked = paths.filter((p) => !untrackedSet.has(p));
|
|
201
|
+
let rootReal;
|
|
202
|
+
try {
|
|
203
|
+
rootReal = await deps.fs.realpath(root);
|
|
204
|
+
} catch {
|
|
205
|
+
return { ok: false, result: { ok: false, error: { code: "git-error", message: "repository root unavailable" } } };
|
|
206
|
+
}
|
|
207
|
+
for (const path of untracked) {
|
|
208
|
+
if (!isSafePath(path)) return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `unsafe path: ${path}` } } };
|
|
209
|
+
const target = join(root, path);
|
|
210
|
+
let targetReal;
|
|
211
|
+
try {
|
|
212
|
+
targetReal = await deps.fs.realpath(target);
|
|
213
|
+
} catch {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep)) {
|
|
217
|
+
return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `path escapes repository: ${path}` } } };
|
|
218
|
+
}
|
|
219
|
+
await deps.fs.remove(target).catch(() => {
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return { ok: true, remainingTracked: tracked };
|
|
223
|
+
}
|
|
224
|
+
export {
|
|
225
|
+
classifyActionFailure,
|
|
226
|
+
isSafePath,
|
|
227
|
+
planAction,
|
|
228
|
+
runAction,
|
|
229
|
+
scrubCredentials
|
|
230
|
+
};
|
package/lib/host/core.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { GitRunner } from './git.
|
|
2
|
-
import type { AgentDefaultModelFace, LlmFace } from './llm-face.
|
|
3
|
-
import type { DiffViewMode, GraphStyle, GitChange, GitErrorCode, GitRemote, GitSnapshotResult } from './types.
|
|
1
|
+
import type { GitRunner } from './git.js';
|
|
2
|
+
import type { AgentDefaultModelFace, LlmFace } from './llm-face.js';
|
|
3
|
+
import type { DiffViewMode, GraphStyle, GitChange, GitErrorCode, GitRemote, GitSnapshotResult } from './types.js';
|
|
4
4
|
export interface GitPanelConfig {
|
|
5
5
|
readonly timeoutMs: number;
|
|
6
6
|
/** Per-command stdout cap; also the per-side image-diff payload cap. */
|
package/lib/host/core.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { commitFromFields, parseStatus, sumNumstat } from "./parser.js";
|
|
3
|
+
import { parseRemote } from "./remote.js";
|
|
4
|
+
import { isSafePath } from "./validate.js";
|
|
5
|
+
const DEFAULT_CONFIG = {
|
|
6
|
+
timeoutMs: 8e3,
|
|
7
|
+
maxBytes: 4 * 1024 * 1024,
|
|
8
|
+
maxChanges: 1e3,
|
|
9
|
+
refreshIntervalMs: 3e4,
|
|
10
|
+
showInputPill: true,
|
|
11
|
+
defaultDiffView: "unified",
|
|
12
|
+
graphStyle: "parallel",
|
|
13
|
+
suggestEnabled: true,
|
|
14
|
+
suggestMaxBytes: 64 * 1024,
|
|
15
|
+
suggestMaxOutputTokens: 200,
|
|
16
|
+
suggestTimeoutMs: 15e3
|
|
17
|
+
};
|
|
18
|
+
function normalizeConfig(raw) {
|
|
19
|
+
const c = raw ?? {};
|
|
20
|
+
const num = (v, d) => typeof v === "number" && Number.isFinite(v) && v >= 1 ? Math.floor(v) : d;
|
|
21
|
+
const hasProvider = typeof c.suggestProvider === "string" && c.suggestProvider !== "";
|
|
22
|
+
const hasModel = typeof c.suggestModel === "string" && c.suggestModel !== "";
|
|
23
|
+
const route = hasProvider && hasModel ? { suggestProvider: c.suggestProvider, suggestModel: c.suggestModel } : {};
|
|
24
|
+
return {
|
|
25
|
+
timeoutMs: num(c.timeoutMs, DEFAULT_CONFIG.timeoutMs),
|
|
26
|
+
maxBytes: num(c.maxBytes, DEFAULT_CONFIG.maxBytes),
|
|
27
|
+
maxChanges: num(c.maxChanges, DEFAULT_CONFIG.maxChanges),
|
|
28
|
+
refreshIntervalMs: num(c.refreshIntervalMs, DEFAULT_CONFIG.refreshIntervalMs),
|
|
29
|
+
showInputPill: readBool(c.showInputPill, DEFAULT_CONFIG.showInputPill),
|
|
30
|
+
defaultDiffView: readDiffView(c.defaultDiffView, DEFAULT_CONFIG.defaultDiffView),
|
|
31
|
+
graphStyle: readGraphStyle(c.graphStyle, DEFAULT_CONFIG.graphStyle),
|
|
32
|
+
suggestEnabled: readBool(c.suggestEnabled, DEFAULT_CONFIG.suggestEnabled),
|
|
33
|
+
suggestMaxBytes: num(c.suggestMaxBytes, DEFAULT_CONFIG.suggestMaxBytes),
|
|
34
|
+
suggestMaxOutputTokens: num(c.suggestMaxOutputTokens, DEFAULT_CONFIG.suggestMaxOutputTokens),
|
|
35
|
+
suggestTimeoutMs: num(c.suggestTimeoutMs, DEFAULT_CONFIG.suggestTimeoutMs),
|
|
36
|
+
...route
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function unwrapVolatile(value) {
|
|
40
|
+
return value !== null && typeof value === "object" && "get" in value && typeof value.get === "function" ? value.get() : value;
|
|
41
|
+
}
|
|
42
|
+
function readDiffView(value, fallback) {
|
|
43
|
+
const raw = unwrapVolatile(value);
|
|
44
|
+
return raw === "unified" || raw === "split" ? raw : fallback;
|
|
45
|
+
}
|
|
46
|
+
function readGraphStyle(value, fallback) {
|
|
47
|
+
const raw = unwrapVolatile(value);
|
|
48
|
+
return raw === "parallel" || raw === "compact" ? raw : fallback;
|
|
49
|
+
}
|
|
50
|
+
function readBool(value, fallback) {
|
|
51
|
+
const raw = unwrapVolatile(value);
|
|
52
|
+
return typeof raw === "boolean" ? raw : fallback;
|
|
53
|
+
}
|
|
54
|
+
const NEG_CACHE_MS = 15e3;
|
|
55
|
+
function parsePrimaryRemote(stdout) {
|
|
56
|
+
const fetchUrls = /* @__PURE__ */ new Map();
|
|
57
|
+
const order = [];
|
|
58
|
+
for (const line of stdout.split("\n")) {
|
|
59
|
+
const m = line.match(/^(\S+)\t(\S+)\s+\(fetch\)$/);
|
|
60
|
+
if (m === null) continue;
|
|
61
|
+
const name2 = m[1];
|
|
62
|
+
if (!fetchUrls.has(name2)) {
|
|
63
|
+
fetchUrls.set(name2, m[2]);
|
|
64
|
+
order.push(name2);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (order.length === 0) return null;
|
|
68
|
+
const name = fetchUrls.has("origin") ? "origin" : order[0];
|
|
69
|
+
return parseRemote(name, fetchUrls.get(name));
|
|
70
|
+
}
|
|
71
|
+
const STATS_FANOUT_LIMIT = 200;
|
|
72
|
+
async function resolveBrowseRoot(deps, sessionId) {
|
|
73
|
+
const ws = await resolveWorkspace(deps, sessionId);
|
|
74
|
+
if (ws.ok) return { ok: true, root: ws.root, isGitRepo: true };
|
|
75
|
+
const err = ws.failure.error;
|
|
76
|
+
if ((err.code === "not-a-git-repo" || err.code === "git-unavailable") && "cwd" in err && err.cwd !== void 0 && err.cwd !== "") {
|
|
77
|
+
try {
|
|
78
|
+
return { ok: true, root: await deps.fs.realpath(err.cwd), isGitRepo: false };
|
|
79
|
+
} catch {
|
|
80
|
+
return { ok: false, error: { code: "git-error" } };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { ok: false, error: mapWorkspaceFailure(ws.failure) };
|
|
84
|
+
}
|
|
85
|
+
async function resolveWorkspace(deps, sessionId) {
|
|
86
|
+
let cwd = deps.sessions.liveCwd(sessionId);
|
|
87
|
+
if (cwd === void 0 || cwd === "") {
|
|
88
|
+
const meta = await deps.sessions.persistedMeta(sessionId);
|
|
89
|
+
cwd = meta?.cwd;
|
|
90
|
+
}
|
|
91
|
+
if (cwd === void 0 || cwd === "") {
|
|
92
|
+
return { ok: false, failure: { ok: false, error: { code: "cwd-unavailable", sessionId } } };
|
|
93
|
+
}
|
|
94
|
+
const cached = deps.rootCache?.get(cwd);
|
|
95
|
+
if (cached !== void 0) return { ok: true, root: cached };
|
|
96
|
+
const negAt = deps.rootNegCache?.get(cwd);
|
|
97
|
+
if (negAt !== void 0) {
|
|
98
|
+
if (Date.now() < negAt) return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
|
|
99
|
+
deps.rootNegCache?.delete(cwd);
|
|
100
|
+
}
|
|
101
|
+
const top = await runCommand(deps.run, ["git", "rev-parse", "--show-toplevel"], cwd, "toplevel", deps.signal);
|
|
102
|
+
if ("failure" in top) {
|
|
103
|
+
return { ok: false, failure: { ok: false, error: { ...mapRunFailure(top.failure), cwd } } };
|
|
104
|
+
}
|
|
105
|
+
if (top.run.cancelled) return { ok: false, failure: { ok: false, error: { code: "cancelled" } } };
|
|
106
|
+
if (top.run.timedOut) return { ok: false, failure: { ok: false, error: { code: "timeout" } } };
|
|
107
|
+
if (top.run.exitCode !== 0) {
|
|
108
|
+
deps.rootNegCache?.set(cwd, Date.now() + NEG_CACHE_MS);
|
|
109
|
+
return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
|
|
110
|
+
}
|
|
111
|
+
const raw = top.run.stdout.trim();
|
|
112
|
+
if (raw === "") {
|
|
113
|
+
deps.rootNegCache?.set(cwd, Date.now() + NEG_CACHE_MS);
|
|
114
|
+
return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
|
|
115
|
+
}
|
|
116
|
+
let root = raw;
|
|
117
|
+
try {
|
|
118
|
+
root = await deps.fs.realpath(raw);
|
|
119
|
+
} catch {
|
|
120
|
+
}
|
|
121
|
+
deps.rootCache?.set(cwd, root);
|
|
122
|
+
return { ok: true, root };
|
|
123
|
+
}
|
|
124
|
+
async function runCommand(runner, argv, cwd, _label, signal, stdinData) {
|
|
125
|
+
try {
|
|
126
|
+
const run = await runner.run(argv, { cwd, ...signal ? { signal } : {}, ...stdinData !== void 0 ? { stdinData } : {} });
|
|
127
|
+
return { run };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return { failure: error };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function mapRunFailure(failure) {
|
|
133
|
+
const message = failure instanceof Error ? failure.message : String(failure);
|
|
134
|
+
return { code: "git-unavailable", detail: message };
|
|
135
|
+
}
|
|
136
|
+
async function detectGitRepo(deps, cwd) {
|
|
137
|
+
let dir;
|
|
138
|
+
try {
|
|
139
|
+
dir = await deps.fs.realpath(cwd);
|
|
140
|
+
} catch {
|
|
141
|
+
dir = cwd;
|
|
142
|
+
}
|
|
143
|
+
for (let i = 0; i < 100; i++) {
|
|
144
|
+
try {
|
|
145
|
+
await deps.fs.stat(join(dir, ".git"));
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
}
|
|
149
|
+
const parent = dirname(dir);
|
|
150
|
+
if (parent === dir) break;
|
|
151
|
+
dir = parent;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
function mapWorkspaceFailure(failure) {
|
|
156
|
+
const error = failure.error;
|
|
157
|
+
const message = "detail" in error ? error.detail : void 0;
|
|
158
|
+
return { code: error.code, ...message !== void 0 ? { message } : {} };
|
|
159
|
+
}
|
|
160
|
+
async function snapshotForSession(deps, config, sessionId) {
|
|
161
|
+
const workspace = await resolveWorkspace(deps, sessionId);
|
|
162
|
+
if (!workspace.ok) {
|
|
163
|
+
const failure = workspace.failure;
|
|
164
|
+
if (failure.error.code === "not-a-git-repo") {
|
|
165
|
+
return { ok: false, error: { ...failure.error, showInputPill: config.showInputPill } };
|
|
166
|
+
}
|
|
167
|
+
if (failure.error.code === "git-unavailable") {
|
|
168
|
+
const cwd = failure.error.cwd;
|
|
169
|
+
const isGitRepo = cwd !== void 0 && cwd !== "" ? await detectGitRepo(deps, cwd) : false;
|
|
170
|
+
return { ok: false, error: { ...failure.error, isGitRepo } };
|
|
171
|
+
}
|
|
172
|
+
return failure;
|
|
173
|
+
}
|
|
174
|
+
const root = workspace.root;
|
|
175
|
+
const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes, remoteRes] = await Promise.all([
|
|
176
|
+
runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], root, "branch", deps.signal),
|
|
177
|
+
runCommand(deps.run, ["git", "rev-parse", "--short", "HEAD"], root, "head", deps.signal),
|
|
178
|
+
runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "status", deps.signal),
|
|
179
|
+
runCommand(deps.run, ["git", "rev-list", "--count", "--left-right", "@{upstream}...HEAD"], root, "aheadBehind", deps.signal),
|
|
180
|
+
runCommand(deps.run, ["git", "log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"], root, "lastCommit", deps.signal),
|
|
181
|
+
runCommand(deps.run, ["git", "diff", "--numstat"], root, "numstat-worktree", deps.signal),
|
|
182
|
+
runCommand(deps.run, ["git", "diff", "--numstat", "--cached"], root, "numstat-staged", deps.signal),
|
|
183
|
+
// `git remote -v` lists every remote's fetch/push URL; parsed into the
|
|
184
|
+
// primary remote (origin, else the first) for the left-column status bar.
|
|
185
|
+
runCommand(deps.run, ["git", "remote", "-v"], root, "remote", deps.signal)
|
|
186
|
+
]);
|
|
187
|
+
const branch = "run" in branchRes && branchRes.run.exitCode === 0 ? branchRes.run.stdout.trim() || null : null;
|
|
188
|
+
const unborn = "run" in headRes && headRes.run.exitCode !== 0;
|
|
189
|
+
const head = "run" in headRes && headRes.run.exitCode === 0 ? headRes.run.stdout.trim() || null : null;
|
|
190
|
+
let allChanges = [];
|
|
191
|
+
let statusLossy = false;
|
|
192
|
+
if ("run" in statusRes && !statusRes.run.timedOut && statusRes.run.exitCode === 0) {
|
|
193
|
+
allChanges = parseStatus(statusRes.run.stdout);
|
|
194
|
+
statusLossy = statusRes.run.stdoutLossy;
|
|
195
|
+
}
|
|
196
|
+
const truncated = allChanges.length > config.maxChanges || statusLossy;
|
|
197
|
+
const changes = allChanges.length > config.maxChanges ? allChanges.slice(0, config.maxChanges) : allChanges;
|
|
198
|
+
let staged = 0;
|
|
199
|
+
let modified = 0;
|
|
200
|
+
let untracked = 0;
|
|
201
|
+
for (const c of allChanges) {
|
|
202
|
+
if (c.status === "untracked") untracked++;
|
|
203
|
+
else if (c.staged) staged++;
|
|
204
|
+
else modified++;
|
|
205
|
+
}
|
|
206
|
+
let ahead = 0;
|
|
207
|
+
let behind = 0;
|
|
208
|
+
const hasUpstream = "run" in aheadBehindRes && aheadBehindRes.run.exitCode === 0;
|
|
209
|
+
if (hasUpstream && "run" in aheadBehindRes) {
|
|
210
|
+
const parts = aheadBehindRes.run.stdout.trim().split(/\s+/);
|
|
211
|
+
behind = Number(parts[0]) || 0;
|
|
212
|
+
ahead = Number(parts[1]) || 0;
|
|
213
|
+
}
|
|
214
|
+
const remote = "run" in remoteRes && remoteRes.run.exitCode === 0 ? parsePrimaryRemote(remoteRes.run.stdout) : null;
|
|
215
|
+
let lastCommit = null;
|
|
216
|
+
if ("run" in lastCommitRes && lastCommitRes.run.exitCode === 0) {
|
|
217
|
+
lastCommit = commitFromFields(lastCommitRes.run.stdout.trim().split(""));
|
|
218
|
+
}
|
|
219
|
+
const wt = "run" in worktreeNumRes && worktreeNumRes.run.exitCode === 0 ? sumNumstat(worktreeNumRes.run.stdout) : { insertions: 0, deletions: 0 };
|
|
220
|
+
const stg = "run" in stagedNumRes && stagedNumRes.run.exitCode === 0 ? sumNumstat(stagedNumRes.run.stdout) : { insertions: 0, deletions: 0 };
|
|
221
|
+
const statsDetailed = allChanges.length <= STATS_FANOUT_LIMIT;
|
|
222
|
+
let untrackedInsertions = 0;
|
|
223
|
+
if (statsDetailed) {
|
|
224
|
+
const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
|
|
225
|
+
const safeUntracked = untrackedPaths.filter(isSafePath);
|
|
226
|
+
if (safeUntracked.length > 0) {
|
|
227
|
+
const perFile = await Promise.all(
|
|
228
|
+
safeUntracked.map((p) => runCommand(deps.run, ["git", "diff", "--numstat", "--no-index", "--", "/dev/null", p], root, "numstat-untracked", deps.signal))
|
|
229
|
+
);
|
|
230
|
+
for (const r of perFile) if ("run" in r) untrackedInsertions += sumNumstat(r.run.stdout).insertions;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const lastChangeAt = statsDetailed ? await maxChangeMtime(deps, root, allChanges) : null;
|
|
234
|
+
const distinct = new Set(allChanges.map((c) => c.path));
|
|
235
|
+
const stats = {
|
|
236
|
+
fileCount: distinct.size,
|
|
237
|
+
staged,
|
|
238
|
+
modified,
|
|
239
|
+
untracked,
|
|
240
|
+
insertions: wt.insertions + stg.insertions + untrackedInsertions,
|
|
241
|
+
deletions: wt.deletions + stg.deletions,
|
|
242
|
+
lastChangeAt,
|
|
243
|
+
headCommittedAt: lastCommit?.dateIso ?? null,
|
|
244
|
+
partial: !statsDetailed
|
|
245
|
+
};
|
|
246
|
+
const snapshot = {
|
|
247
|
+
root,
|
|
248
|
+
branch,
|
|
249
|
+
head,
|
|
250
|
+
unborn,
|
|
251
|
+
dirty: allChanges.length > 0,
|
|
252
|
+
staged,
|
|
253
|
+
modified,
|
|
254
|
+
untracked,
|
|
255
|
+
ahead,
|
|
256
|
+
behind,
|
|
257
|
+
hasUpstream,
|
|
258
|
+
remote,
|
|
259
|
+
lastCommit,
|
|
260
|
+
changes,
|
|
261
|
+
stats,
|
|
262
|
+
truncated,
|
|
263
|
+
refreshIntervalMs: config.refreshIntervalMs,
|
|
264
|
+
showInputPill: config.showInputPill,
|
|
265
|
+
defaultDiffView: config.defaultDiffView,
|
|
266
|
+
graphStyle: config.graphStyle,
|
|
267
|
+
suggestEnabled: config.suggestEnabled,
|
|
268
|
+
checkedAt: Date.now()
|
|
269
|
+
};
|
|
270
|
+
return { ok: true, value: snapshot };
|
|
271
|
+
}
|
|
272
|
+
async function maxChangeMtime(deps, root, changes, cap = 200) {
|
|
273
|
+
let max = null;
|
|
274
|
+
const slice = changes.slice(0, cap);
|
|
275
|
+
await Promise.all(slice.map(async (c) => {
|
|
276
|
+
if (c.isDirectory) return;
|
|
277
|
+
try {
|
|
278
|
+
const info = await deps.fs.stat(join(root, c.path));
|
|
279
|
+
if (Number.isFinite(info.mtimeMs)) {
|
|
280
|
+
max = max === null ? info.mtimeMs : Math.max(max, info.mtimeMs);
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
}
|
|
284
|
+
}));
|
|
285
|
+
return max;
|
|
286
|
+
}
|
|
287
|
+
export {
|
|
288
|
+
DEFAULT_CONFIG,
|
|
289
|
+
mapWorkspaceFailure,
|
|
290
|
+
maxChangeMtime,
|
|
291
|
+
normalizeConfig,
|
|
292
|
+
parsePrimaryRemote,
|
|
293
|
+
readBool,
|
|
294
|
+
readDiffView,
|
|
295
|
+
readGraphStyle,
|
|
296
|
+
resolveBrowseRoot,
|
|
297
|
+
resolveWorkspace,
|
|
298
|
+
runCommand,
|
|
299
|
+
snapshotForSession
|
|
300
|
+
};
|
package/lib/host/git.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
function createGitRunner(subprocess, timeoutMs, maxBytes) {
|
|
3
|
+
const spillMaxBytes = maxBytes * 16;
|
|
4
|
+
return {
|
|
5
|
+
async run(argv, opts) {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
8
|
+
try {
|
|
9
|
+
const signal = opts.signal === void 0 ? controller.signal : AbortSignal.any([controller.signal, opts.signal]);
|
|
10
|
+
const handle = subprocess.spawn({
|
|
11
|
+
argv,
|
|
12
|
+
cwd: opts.cwd,
|
|
13
|
+
// Never let a network command (fetch/pull) block on an interactive
|
|
14
|
+
// credential or host-key prompt: with stdin ignored git would hang
|
|
15
|
+
// until the timeout. GIT_TERMINAL_PROMPT=0 makes it fail fast instead,
|
|
16
|
+
// and is harmless for the non-network commands that share this runner.
|
|
17
|
+
env: { GIT_TERMINAL_PROMPT: "0" },
|
|
18
|
+
stdio: {
|
|
19
|
+
stdin: opts.stdinData === void 0 ? "ignore" : { data: opts.stdinData },
|
|
20
|
+
stdout: { collect: { maxBytes, spill: { maxBytes: spillMaxBytes } } },
|
|
21
|
+
stderr: { collect: { maxBytes, spill: { maxBytes: spillMaxBytes } } }
|
|
22
|
+
},
|
|
23
|
+
graceMs: 200,
|
|
24
|
+
signal
|
|
25
|
+
});
|
|
26
|
+
let outcome;
|
|
27
|
+
try {
|
|
28
|
+
outcome = await handle.done;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const cancelled2 = opts.signal?.aborted === true && !controller.signal.aborted;
|
|
31
|
+
if (controller.signal.aborted || opts.signal?.aborted === true) {
|
|
32
|
+
return { exitCode: null, stdout: "", stderr: "", timedOut: !cancelled2, cancelled: cancelled2, stdoutLossy: false };
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
const stdout = handle.collected.stdout?.readFrom(0);
|
|
37
|
+
const stderr = handle.collected.stderr?.readFrom(0);
|
|
38
|
+
const resolved = await resolveStdout(stdout);
|
|
39
|
+
const cancelled = opts.signal?.aborted === true && !controller.signal.aborted;
|
|
40
|
+
return {
|
|
41
|
+
exitCode: outcome.exitCode,
|
|
42
|
+
stdout: resolved.text,
|
|
43
|
+
stderr: stderr?.text ?? "",
|
|
44
|
+
timedOut: controller.signal.aborted,
|
|
45
|
+
cancelled,
|
|
46
|
+
stdoutLossy: resolved.lossy
|
|
47
|
+
};
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
async function resolveStdout(read) {
|
|
55
|
+
if (read === void 0) return { text: "", lossy: false };
|
|
56
|
+
if (!read.lossy || read.spillPath === void 0) return { text: read.text, lossy: read.lossy };
|
|
57
|
+
try {
|
|
58
|
+
return { text: await readFile(read.spillPath, "utf8"), lossy: false };
|
|
59
|
+
} catch {
|
|
60
|
+
return { text: read.text, lossy: true };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export {
|
|
64
|
+
createGitRunner
|
|
65
|
+
};
|
package/lib/host/index.d.ts
CHANGED
|
@@ -9,16 +9,16 @@
|
|
|
9
9
|
import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
10
10
|
import type { Context } from '@deepseek-ai/cordis';
|
|
11
11
|
import Schema from '@deepseek-ai/schemastery';
|
|
12
|
-
import type { GitActionRequest, GitActionResult, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult, GitSuggestRequest, GitSuggestResult, GitVersionInfo, GitVersionRequest } from './types.
|
|
13
|
-
export type { GitSnapshot, GitSnapshotResult, GitSnapshotRequest, GitFailure, GitCommit, GraphCommit, GitRef, GitChange, GitChangeStatus, GitAction, GitActionRequest, GitActionResult, GitErrorCode, GitQuery, GitQueryRequest, GitQueryResponse, GitQueryResult, GitBranch, GitFileStat, WorktreeStats, StashEntry, GitVersionRequest, GitVersionInfo, DiffViewMode, GraphStyle, DirEntry, ResetMode, GitSuggestRequest, GitSuggestResult, GitRemote, RemoteHostKind, } from './types.
|
|
14
|
-
export { normalizeConfig, DEFAULT_CONFIG, snapshotForSession, resolveWorkspace, parsePrimaryRemote } from './core.
|
|
15
|
-
export { createGitRunner } from './git.
|
|
16
|
-
export { parseStatus, parseGraphLog, parseBranches, parseNameStatus, parseStashList, parseTags, sumNumstat } from './parser.
|
|
17
|
-
export { parseRemote, classifyHost } from './remote.
|
|
18
|
-
export { isSafePath, planAction, runAction, classifyActionFailure } from './actions.
|
|
19
|
-
export { runQuery } from './queries.
|
|
20
|
-
export { runSuggest } from './suggest.
|
|
21
|
-
export { readVersionInfo, checkLatestVersion, compareVersions, parseRepository } from './version.
|
|
12
|
+
import type { GitActionRequest, GitActionResult, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult, GitSuggestRequest, GitSuggestResult, GitVersionInfo, GitVersionRequest } from './types.js';
|
|
13
|
+
export type { GitSnapshot, GitSnapshotResult, GitSnapshotRequest, GitFailure, GitCommit, GraphCommit, GitRef, GitChange, GitChangeStatus, GitAction, GitActionRequest, GitActionResult, GitErrorCode, GitQuery, GitQueryRequest, GitQueryResponse, GitQueryResult, GitBranch, GitFileStat, WorktreeStats, StashEntry, GitVersionRequest, GitVersionInfo, DiffViewMode, GraphStyle, DirEntry, ResetMode, GitSuggestRequest, GitSuggestResult, GitRemote, RemoteHostKind, } from './types.js';
|
|
14
|
+
export { normalizeConfig, DEFAULT_CONFIG, snapshotForSession, resolveWorkspace, parsePrimaryRemote } from './core.js';
|
|
15
|
+
export { createGitRunner } from './git.js';
|
|
16
|
+
export { parseStatus, parseGraphLog, parseBranches, parseNameStatus, parseStashList, parseTags, sumNumstat } from './parser.js';
|
|
17
|
+
export { parseRemote, classifyHost } from './remote.js';
|
|
18
|
+
export { isSafePath, planAction, runAction, classifyActionFailure } from './actions.js';
|
|
19
|
+
export { runQuery } from './queries.js';
|
|
20
|
+
export { runSuggest } from './suggest.js';
|
|
21
|
+
export { readVersionInfo, checkLatestVersion, compareVersions, parseRepository } from './version.js';
|
|
22
22
|
export declare class GitPanelService extends TypertRemoteService {
|
|
23
23
|
static inject: string[];
|
|
24
24
|
/**
|