@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.
@@ -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 run = async (root, args, disableFilters = false, allowNoMatches = false) => {
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
- const child = executor.spawn({
75
- argv: [
76
- process.platform === "win32" ? "git.exe" : "git",
77
- "-c", "core.fsmonitor=false",
78
- "-c", `core.hooksPath=${noHooksPath}`,
79
- ...filterOverrides,
80
- "-C", root,
81
- "--no-optional-locks",
82
- ...args,
83
- ],
84
- cwd: root,
85
- // Prevent ambient Git repository/config overrides from redirecting a
86
- // session-authorized read. Undefined values are seam tombstones.
87
- env: {
88
- GIT_TERMINAL_PROMPT: "0",
89
- GCM_INTERACTIVE: "never",
90
- GIT_DIR: undefined,
91
- GIT_WORK_TREE: undefined,
92
- GIT_INDEX_FILE: undefined,
93
- GIT_COMMON_DIR: undefined,
94
- GIT_OBJECT_DIRECTORY: undefined,
95
- GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined,
96
- GIT_CONFIG: undefined,
97
- GIT_CONFIG_PARAMETERS: undefined,
98
- GIT_CONFIG_COUNT: "0",
99
- // Trace destinations open files even for otherwise read-only Git commands.
100
- ...Object.fromEntries(Object.keys(process.env).filter((key) => /^GIT_TRACE(?:2)?(?:_|$)/.test(key)).map((key) => [key, undefined])),
101
- GIT_TRACE: undefined,
102
- GIT_TRACE2: undefined,
103
- GIT_TRACE2_EVENT: undefined,
104
- GIT_TRACE2_PERF: undefined,
105
- },
106
- stdio: { stdin: "ignore", stdout: { maxBytes: 8 * 1024 * 1024 }, stderr: { maxBytes: 1024 * 1024 } },
107
- graceMs: 10_000,
108
- });
109
- const done = await child.done;
110
- const stdoutResult = await child.collected?.stdout?.readFrom?.(0);
111
- const stderrResult = await child.collected?.stderr?.readFrom?.(0);
112
- if (stdoutResult?.lossy || stderrResult?.lossy) {
113
- throw gitError("git-output-too-large", 413);
114
- }
115
- const stdout = stdoutResult?.text ?? "";
116
- const stderr = stderrResult?.text ?? "";
117
- if (done?.exitCode !== 0) {
118
- if (allowNoMatches && done?.exitCode === 1) return "";
119
- if (/not a git repository/i.test(stderr)) throw gitError("not-git-repository", 404);
120
- throw gitError("git-command-failed", 400);
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.0.0",
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 { oid: hash, parents: parentText ? parentText.split(" ") : [], author, timestamp: Number(timestamp) || 0, message: messageParts.join("\x1f").trim(), files };
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);