@marktrue/dsh-mobile-remote 3.2.3-rc.1 → 3.2.4-alpha.1
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/CHANGELOG.md +92 -44
- package/docs/03-api.md +227 -3
- package/docs/10-release.md +177 -39
- package/lib/endpoint-registry.js +2 -0
- package/lib/git-read-bundled-provider.js +248 -53
- package/lib/git-read-service.js +293 -14
- package/lib/index.js +25 -12
- package/package.json +2 -2
- package/docs/spec-app-auto-update.md +0 -143
|
@@ -41,6 +41,51 @@ function gitError(code, status, message = code) {
|
|
|
41
41
|
return error;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
const refShortName = (name) => name === "HEAD" ? "HEAD" : String(name).replace(/^refs\/(?:heads|remotes|tags)\//, "");
|
|
45
|
+
const HISTORY_STATUS_LETTER = { A: "A", M: "M", D: "D", R: "R", C: "C", T: "T" };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse `git log --format=%x00<meta> --name-status -M -z` output into file
|
|
49
|
+
* history entries. Records are NUL-delimited: a `\x00` start marker, the
|
|
50
|
+
* `\x1f`-separated metadata, then `\x00\n<status>` and its NUL-separated paths.
|
|
51
|
+
* A deadline-aborted command may end mid-record; incomplete trailing records
|
|
52
|
+
* are ignored while every fully parsed commit stays usable.
|
|
53
|
+
*/
|
|
54
|
+
function parseFileHistory(stdout) {
|
|
55
|
+
const tokens = String(stdout ?? "").split("\0");
|
|
56
|
+
const entries = [];
|
|
57
|
+
let index = 0;
|
|
58
|
+
while (index < tokens.length) {
|
|
59
|
+
const metadata = tokens[index++];
|
|
60
|
+
if (!metadata || metadata.startsWith("\n")) continue;
|
|
61
|
+
const fields = metadata.split("\x1f");
|
|
62
|
+
if (fields.length < 5) continue;
|
|
63
|
+
const oid = fields[0];
|
|
64
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(oid)) continue;
|
|
65
|
+
let status = "M";
|
|
66
|
+
if (index < tokens.length) {
|
|
67
|
+
const marker = tokens[index];
|
|
68
|
+
if (typeof marker === "string" && marker.startsWith("\n")) {
|
|
69
|
+
index++;
|
|
70
|
+
const code = marker.slice(1).trim();
|
|
71
|
+
if (code) {
|
|
72
|
+
status = HISTORY_STATUS_LETTER[code[0]] ?? "M";
|
|
73
|
+
index += /^[RC]/.test(code) ? 2 : 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
entries.push({
|
|
78
|
+
oid,
|
|
79
|
+
shortOid: fields[1] || oid.slice(0, 7),
|
|
80
|
+
author: fields[2] ?? "",
|
|
81
|
+
timestamp: Number(fields[3]) || 0,
|
|
82
|
+
subject: fields.slice(4).join("\x1f"),
|
|
83
|
+
status,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return entries;
|
|
87
|
+
}
|
|
88
|
+
|
|
44
89
|
/** Bundled, read-only Git provider. Every command uses the host subprocess service. */
|
|
45
90
|
export function createBundledGitReadProvider(ctx) {
|
|
46
91
|
const get = (name) => {
|
|
@@ -61,7 +106,7 @@ export function createBundledGitReadProvider(ctx) {
|
|
|
61
106
|
if (!roots.some((root) => root && inside(canonical, root))) throw gitError("workspace-not-allowed", 403);
|
|
62
107
|
return canonical;
|
|
63
108
|
};
|
|
64
|
-
const
|
|
109
|
+
const spawnGit = async (root, args, { disableFilters = false, allowNoMatches = false, timeoutMs = 0 } = {}) => {
|
|
65
110
|
root = await assertWorkspace(root);
|
|
66
111
|
const executor = subprocess();
|
|
67
112
|
if (!executor?.spawn) throw gitError("git-provider-unavailable", 503);
|
|
@@ -71,63 +116,80 @@ export function createBundledGitReadProvider(ctx) {
|
|
|
71
116
|
const names = await run(root, ["config", "--null", "--name-only", "--get-regexp", "^filter\\..*\\.(clean|process)$"], false, true);
|
|
72
117
|
filterOverrides = names.split("\0").filter(Boolean).flatMap((name) => ["-c", `${name}=`]);
|
|
73
118
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
// A soft deadline aborts the command and returns the output collected so far,
|
|
120
|
+
// so file history can degrade to a partial, explicitly truncated page instead
|
|
121
|
+
// of failing the request. Hardening below is shared with the batch helper.
|
|
122
|
+
const controller = timeoutMs > 0 ? new AbortController() : null;
|
|
123
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
124
|
+
try {
|
|
125
|
+
const child = executor.spawn({
|
|
126
|
+
argv: [
|
|
127
|
+
process.platform === "win32" ? "git.exe" : "git",
|
|
128
|
+
"-c", "core.fsmonitor=false",
|
|
129
|
+
"-c", `core.hooksPath=${noHooksPath}`,
|
|
130
|
+
...filterOverrides,
|
|
131
|
+
"-C", root,
|
|
132
|
+
"--no-optional-locks",
|
|
133
|
+
...args,
|
|
134
|
+
],
|
|
135
|
+
cwd: root,
|
|
136
|
+
// Prevent ambient Git repository/config overrides from redirecting a
|
|
137
|
+
// session-authorized read. Undefined values are seam tombstones.
|
|
138
|
+
env: {
|
|
139
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
140
|
+
GCM_INTERACTIVE: "never",
|
|
141
|
+
GIT_DIR: undefined,
|
|
142
|
+
GIT_WORK_TREE: undefined,
|
|
143
|
+
GIT_INDEX_FILE: undefined,
|
|
144
|
+
GIT_COMMON_DIR: undefined,
|
|
145
|
+
GIT_OBJECT_DIRECTORY: undefined,
|
|
146
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined,
|
|
147
|
+
GIT_CONFIG: undefined,
|
|
148
|
+
GIT_CONFIG_PARAMETERS: undefined,
|
|
149
|
+
GIT_CONFIG_COUNT: "0",
|
|
150
|
+
// Trace destinations open files even for otherwise read-only Git commands.
|
|
151
|
+
...Object.fromEntries(Object.keys(process.env).filter((key) => /^GIT_TRACE(?:2)?(?:_|$)/.test(key)).map((key) => [key, undefined])),
|
|
152
|
+
GIT_TRACE: undefined,
|
|
153
|
+
GIT_TRACE2: undefined,
|
|
154
|
+
GIT_TRACE2_EVENT: undefined,
|
|
155
|
+
GIT_TRACE2_PERF: undefined,
|
|
156
|
+
},
|
|
157
|
+
stdio: { stdin: "ignore", stdout: { maxBytes: 8 * 1024 * 1024 }, stderr: { maxBytes: 1024 * 1024 } },
|
|
158
|
+
graceMs: 10_000,
|
|
159
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
160
|
+
});
|
|
161
|
+
let done;
|
|
162
|
+
try { done = await child.done; } catch (error) {
|
|
163
|
+
if (!controller?.signal.aborted) throw error;
|
|
164
|
+
done = { exitCode: null };
|
|
165
|
+
}
|
|
166
|
+
const stdoutResult = await child.collected?.stdout?.readFrom?.(0);
|
|
167
|
+
const stderrResult = await child.collected?.stderr?.readFrom?.(0);
|
|
168
|
+
if (stdoutResult?.lossy || stderrResult?.lossy) {
|
|
169
|
+
throw gitError("git-output-too-large", 413);
|
|
170
|
+
}
|
|
171
|
+
const stdout = stdoutResult?.text ?? "";
|
|
172
|
+
const stderr = stderrResult?.text ?? "";
|
|
173
|
+
if (controller?.signal.aborted) return { stdout, stderr, timedOut: true };
|
|
174
|
+
if (done?.exitCode !== 0) {
|
|
175
|
+
if (allowNoMatches && done?.exitCode === 1) return { stdout: "", stderr, timedOut: false };
|
|
176
|
+
if (/not a git repository/i.test(stderr)) throw gitError("not-git-repository", 404);
|
|
177
|
+
throw gitError("git-command-failed", 400);
|
|
178
|
+
}
|
|
179
|
+
return { stdout, stderr, timedOut: false };
|
|
180
|
+
} finally {
|
|
181
|
+
if (timer) clearTimeout(timer);
|
|
121
182
|
}
|
|
122
|
-
return stdout;
|
|
123
183
|
};
|
|
184
|
+
const run = async (root, args, disableFilters = false, allowNoMatches = false) =>
|
|
185
|
+
(await spawnGit(root, args, { disableFilters, allowNoMatches })).stdout;
|
|
124
186
|
const repositoryRoot = async (cwd) => {
|
|
125
187
|
const allowed = await assertWorkspace(cwd);
|
|
126
188
|
const root = (await run(allowed, ["rev-parse", "--show-toplevel"])).trim();
|
|
127
189
|
return assertWorkspace(root);
|
|
128
190
|
};
|
|
129
191
|
return {
|
|
130
|
-
contractVersion: "1.
|
|
192
|
+
contractVersion: "1.1.0",
|
|
131
193
|
capabilities() {
|
|
132
194
|
const available = Boolean(subprocess()?.spawn) && workspaces().length > 0;
|
|
133
195
|
return { available, reason: available ? null : !subprocess()?.spawn ? "git-provider-unavailable" : "workspace-registry-unavailable" };
|
|
@@ -157,8 +219,8 @@ export function createBundledGitReadProvider(ctx) {
|
|
|
157
219
|
},
|
|
158
220
|
async commit(root, oid) {
|
|
159
221
|
if (typeof oid !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(oid)) throw gitError("invalid-oid", 400);
|
|
160
|
-
const metadata = (await run(root, ["show", "-s", "--format=%H%x1f%P%x1f%an%x1f%at%x1f%B%x00", oid])).split("\0")[0].split("\x1f");
|
|
161
|
-
const [hash, parentText, author, timestamp, ...messageParts] = metadata;
|
|
222
|
+
const metadata = (await run(root, ["show", "-s", "--decorate=full", "--format=%H%x1f%P%x1f%an%x1f%at%x1f%D%x1f%B%x00", oid])).split("\0")[0].split("\x1f");
|
|
223
|
+
const [hash, parentText, author, timestamp, decorations, ...messageParts] = metadata;
|
|
162
224
|
const diffBase = parentText ? [parentText.split(" ")[0], hash] : ["--root", hash];
|
|
163
225
|
const statusParts = (await run(root, ["diff-tree", "--no-commit-id", "-r", "-M", "--name-status", "-z", ...diffBase])).split("\0");
|
|
164
226
|
const files = [];
|
|
@@ -187,7 +249,140 @@ export function createBundledGitReadProvider(ctx) {
|
|
|
187
249
|
stats.set(path, { binary: added === "-" || deleted === "-", additions: added === "-" ? null : Number(added), deletions: deleted === "-" ? null : Number(deleted) });
|
|
188
250
|
}
|
|
189
251
|
for (const file of files) Object.assign(file, stats.get(file.path) ?? { binary: false, additions: 0, deletions: 0 });
|
|
190
|
-
return {
|
|
252
|
+
return {
|
|
253
|
+
oid: hash, parents: parentText ? parentText.split(" ") : [], author, timestamp: Number(timestamp) || 0,
|
|
254
|
+
message: messageParts.join("\x1f").trim(),
|
|
255
|
+
refs: decorations ? decorations.split(",").map((value) => value.trim()).filter(Boolean) : [],
|
|
256
|
+
files,
|
|
257
|
+
};
|
|
258
|
+
},
|
|
259
|
+
/**
|
|
260
|
+
* All branch, remote, tag and HEAD refs with name-level facts plus
|
|
261
|
+
* annotated-tag metadata. Non-commit and nested tag targets carry a hint
|
|
262
|
+
* instead of failing the whole read.
|
|
263
|
+
*/
|
|
264
|
+
async refs(root) {
|
|
265
|
+
const records = [];
|
|
266
|
+
// NUL/US-delimited fields keep tagger names and tag subjects that contain
|
|
267
|
+
// tabs intact; a tab-delimited line format would corrupt such records.
|
|
268
|
+
const text = await run(root, ["for-each-ref", "--format=%(refname)%1f%(objectname)%1f%(objecttype)%1f%(HEAD)%1f%(taggername)%1f%(taggerdate:unix)%1f%(subject)%1f%(*objectname)%1f%(*objecttype)%00", "refs/heads", "refs/remotes", "refs/tags"]);
|
|
269
|
+
for (const raw of text.split("\0")) {
|
|
270
|
+
const record = raw.replace(/^[\r\n]+|[\r\n]+$/g, "");
|
|
271
|
+
if (!record) continue;
|
|
272
|
+
const fields = record.split("\x1f");
|
|
273
|
+
if (fields.length < 9) continue;
|
|
274
|
+
// Peeled fields are fixed at the tail so a subject containing the
|
|
275
|
+
// unit separator cannot swallow them.
|
|
276
|
+
const [name, oid, objectType, headFlag, taggerName, taggerDate] = fields;
|
|
277
|
+
const peeledType = fields.at(-1);
|
|
278
|
+
const peeledOid = fields.at(-2);
|
|
279
|
+
const subject = fields.slice(6, -2).join("\x1f");
|
|
280
|
+
if (!name || !oid) continue;
|
|
281
|
+
if (name.startsWith("refs/remotes/") && name.endsWith("/HEAD")) continue;
|
|
282
|
+
if (name.startsWith("refs/heads/") || name.startsWith("refs/remotes/")) {
|
|
283
|
+
const commit = objectType === "commit";
|
|
284
|
+
records.push({
|
|
285
|
+
name, shortName: refShortName(name),
|
|
286
|
+
kind: name.startsWith("refs/heads/") ? "localBranch" : "remoteBranch",
|
|
287
|
+
oid, commitOid: commit ? oid : null, current: headFlag === "*", annotated: false,
|
|
288
|
+
...(commit ? {} : { hint: "non-commit-target" }),
|
|
289
|
+
});
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (!name.startsWith("refs/tags/")) continue;
|
|
293
|
+
if (objectType !== "tag") {
|
|
294
|
+
// Lightweight tag: the ref itself points at the target object.
|
|
295
|
+
const commit = objectType === "commit";
|
|
296
|
+
records.push({
|
|
297
|
+
name, shortName: refShortName(name), kind: "tag",
|
|
298
|
+
oid, commitOid: commit ? oid : null, current: false, annotated: false,
|
|
299
|
+
...(commit ? {} : { hint: "non-commit-target" }),
|
|
300
|
+
});
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
let commitOid = null, hint;
|
|
304
|
+
if (peeledType === "commit") commitOid = peeledOid;
|
|
305
|
+
else if (peeledType === "tag") {
|
|
306
|
+
// Nested tag: peel recursively; when the chain ends outside a
|
|
307
|
+
// commit we still keep the name and advertise the hint.
|
|
308
|
+
try { commitOid = (await run(root, ["rev-parse", "--verify", `${name}^{commit}`])).trim() || null; } catch { commitOid = null; }
|
|
309
|
+
hint = commitOid ? "nested-tag" : "non-commit-target";
|
|
310
|
+
} else hint = "non-commit-target";
|
|
311
|
+
const date = Number(taggerDate);
|
|
312
|
+
records.push({
|
|
313
|
+
name, shortName: refShortName(name), kind: "tag", oid, commitOid, current: false, annotated: true,
|
|
314
|
+
...(taggerName ? { tagger: taggerName } : {}),
|
|
315
|
+
...(Number.isFinite(date) && date > 0 ? { date } : {}),
|
|
316
|
+
...(subject ? { subject } : {}),
|
|
317
|
+
...(hint ? { hint } : {}),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
let headOid = null;
|
|
321
|
+
try { headOid = (await run(root, ["rev-parse", "--verify", "HEAD"])).trim() || null; } catch (error) { if (error?.code !== "git-command-failed") throw error; }
|
|
322
|
+
if (headOid) records.unshift({ name: "HEAD", shortName: "HEAD", kind: "head", oid: headOid, commitOid: headOid, current: true, annotated: false });
|
|
323
|
+
return records;
|
|
324
|
+
},
|
|
325
|
+
/** Tracked files at HEAD, bounded and filtered by a case-insensitive path substring. */
|
|
326
|
+
async files(root, { q = "", limit = 200 } = {}) {
|
|
327
|
+
const pageSize = Math.max(1, Math.min(200, Number(limit) || 200));
|
|
328
|
+
// The recursive HEAD path list is bounded by the subprocess stdout cap
|
|
329
|
+
// (8 MiB, see spawnGit). Repositories above that cap fail closed with
|
|
330
|
+
// `git-output-too-large` (413) instead of returning a partial list; the
|
|
331
|
+
// bound is documented in docs/03-api.md §6.19.7.
|
|
332
|
+
let text;
|
|
333
|
+
try { text = await run(root, ["ls-tree", "-r", "-z", "--name-only", "HEAD"]); } catch (error) {
|
|
334
|
+
if (error?.code !== "git-command-failed") throw error;
|
|
335
|
+
// An unborn HEAD is an empty repository, not a failure: /git/files
|
|
336
|
+
// must answer 200 with an empty list for it.
|
|
337
|
+
try { await run(root, ["rev-parse", "--verify", "HEAD"]); } catch (inner) {
|
|
338
|
+
if (inner?.code === "git-command-failed") return { files: [], truncated: false };
|
|
339
|
+
throw inner;
|
|
340
|
+
}
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
const query = typeof q === "string" ? q.toLowerCase() : "";
|
|
344
|
+
let paths = text.split("\0").filter((path) => path !== "");
|
|
345
|
+
if (query) paths = paths.filter((path) => path.toLowerCase().includes(query));
|
|
346
|
+
paths.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
347
|
+
const truncated = paths.length > pageSize;
|
|
348
|
+
return { files: paths.slice(0, pageSize), truncated };
|
|
349
|
+
},
|
|
350
|
+
/**
|
|
351
|
+
* Commits touching one file, newest first, with the change status that
|
|
352
|
+
* commit applied to the path. Bounded by `maxCommits` or the soft
|
|
353
|
+
* deadline; both report an explicitly truncated (partial) list.
|
|
354
|
+
*/
|
|
355
|
+
async fileHistory(root, path, { maxCommits = 5000, timeoutMs = 20000 } = {}) {
|
|
356
|
+
if (typeof path !== "string" || path === "" || path.includes("\0")) throw gitError("git-command-failed", 400);
|
|
357
|
+
try { await run(root, ["rev-parse", "--verify", "HEAD"]); } catch (error) {
|
|
358
|
+
if (error?.code === "git-command-failed") return { entries: [], truncated: false, reason: "no-history" };
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
let tracked = false;
|
|
362
|
+
try {
|
|
363
|
+
const tree = await run(root, ["ls-tree", "-z", "HEAD", "--", `:(literal)${path}`]);
|
|
364
|
+
for (const record of tree.split("\0")) {
|
|
365
|
+
const separator = record.indexOf("\t");
|
|
366
|
+
if (separator < 0) continue;
|
|
367
|
+
const entryPath = record.slice(separator + 1);
|
|
368
|
+
const entryType = record.slice(0, separator).split(" ")[1];
|
|
369
|
+
// Directories, gitlinks and other tree entries are not file paths;
|
|
370
|
+
// only blobs (regular files and symlinks) have a file history.
|
|
371
|
+
if (entryPath === path && entryType === "blob") tracked = true;
|
|
372
|
+
}
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (error?.code !== "git-command-failed") throw error;
|
|
375
|
+
}
|
|
376
|
+
if (!tracked) return { entries: [], truncated: false, reason: "not-tracked" };
|
|
377
|
+
const cap = Math.max(1, Math.min(5000, Number(maxCommits) || 5000));
|
|
378
|
+
const result = await spawnGit(root, ["--literal-pathspecs", "log", `--max-count=${cap + 1}`, "--format=%x00%H%x1f%h%x1f%an%x1f%at%x1f%s", "--name-status", "-M", "-z", "--", path], { timeoutMs });
|
|
379
|
+
let entries = parseFileHistory(result.stdout);
|
|
380
|
+
let truncated = result.timedOut === true;
|
|
381
|
+
if (entries.length > cap) {
|
|
382
|
+
entries = entries.slice(0, cap);
|
|
383
|
+
truncated = true;
|
|
384
|
+
}
|
|
385
|
+
return { entries, truncated };
|
|
191
386
|
},
|
|
192
387
|
async worktree(root) {
|
|
193
388
|
const text = await run(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], true);
|