dsh-plugin-wsl-env 0.6.0 → 0.7.3

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.
@@ -0,0 +1,284 @@
1
+ /**
2
+ * The distro-side traversal strategy for `@` completion — the plugin's
3
+ * ready answer to RFC addendum ① (docs/upstream/rfc-wsl-workspaces.md).
4
+ *
5
+ * ## The seam this consumes
6
+ *
7
+ * `@deepseek-ai/dsh-file-reference-local` funnels every workspace traversal
8
+ * through three module-internal functions: `readWorkspaceRoot` and
9
+ * `readDirectory` (both `node:fs/promises` readdir with `withFileTypes`) and
10
+ * `resolveDisplayDirectory` (a lexical resolve plus a per-segment `lstat`
11
+ * walk that refuses symlink and non-directory components). Everything else —
12
+ * the fuzzy ranking, the bounded index, the generation/invalidation dance,
13
+ * the excluded-directory list — is pure logic over the entries those three
14
+ * return. Addendum ① proposes exposing the three as an injectable strategy
15
+ * with the current implementations as defaults; this module is the consumer
16
+ * side, built and tested against that interface so the takeover lands the
17
+ * day the seam does.
18
+ *
19
+ * It is deliberately NOT wired into cordis.patch.yml yet: with the seam
20
+ * absent, reaching the funnels would mean subclassing past module-internal
21
+ * functions or re-implementing the ranking engine — the behavior-regression
22
+ * path the RFC rules out. The module ships, tested, until upstream speaks.
23
+ *
24
+ * ## Semantics, held faithful where the seam allows
25
+ *
26
+ * - Entries keep upstream's Dirent shape: `{name, isDirectory(), isFile()}`.
27
+ * The distro listing uses `find -printf '%y\t%f'` rather than `ls -1ALp`
28
+ * precisely for this: `%y` reports `l` for symlinks, which the parse skips
29
+ * the way a readdir Dirent is neither file nor directory. `ls -1ALp` would
30
+ * resolve links through and change which candidates exist.
31
+ * - `resolveDisplayDirectory` refuses a symlink or non-directory component
32
+ * with one agent exec that walks the (lexically normalized) segments —
33
+ * the same verdict upstream reaches with N sequential lstats, and unlike
34
+ * `realpath`, refusing is the point, not resolving.
35
+ * - A directory the traversal cannot read yields `[]` (upstream's readdir
36
+ * catch); an aborted caller propagates the abort instead.
37
+ * - The returned absolutes stay in the root's own coordinate system (UNC for
38
+ * a distro workspace), because upstream joins children with the host
39
+ * `node:path` — a POSIX path from this layer would be mangled by it.
40
+ *
41
+ * @module dsh-plugin-wsl/file-reference-wsl
42
+ */
43
+
44
+ import { isWslUnc, posixToUnc, uncToPosix } from "./paths.js";
45
+
46
+ /** Upstream's default cap on one workspace index; the per-directory listing
47
+ * rides the same bound, since one level can't usefully exceed the budget. */
48
+ const DEFAULT_MAX_ENTRIES = 50000;
49
+
50
+ /**
51
+ * Lexically normalize a `/`-separated display directory against its root.
52
+ * Upstream resolves `resolve(root, displayDirectory)` and refuses a result
53
+ * outside the root, so `a/../b` lands on `b` while `..` past the root is
54
+ * refused outright — the walk below never sees a `..` segment.
55
+ *
56
+ * @param {string} displayDirectory - the query text before the fragment, possibly "".
57
+ * @returns {string[] | undefined} the normalized segments, or undefined when
58
+ * the display directory escapes the root.
59
+ */
60
+ export function normalizeDisplaySegments(displayDirectory) {
61
+ const segments = [];
62
+ for (const segment of String(displayDirectory).split("/")) {
63
+ if (segment === "" || segment === ".") continue;
64
+ if (segment === "..") {
65
+ if (segments.length === 0) return undefined;
66
+ segments.pop();
67
+ continue;
68
+ }
69
+ segments.push(segment);
70
+ }
71
+ return segments;
72
+ }
73
+
74
+ /**
75
+ * The distro argv for one directory listing: `find -printf '%y\t%f'` over the
76
+ * contained directory. The containment guard is the same shape the picker and
77
+ * workspace-files use — `realpath` the requested directory, refuse anything
78
+ * the root does not contain — and the `%y` type char is what keeps symlink
79
+ * semantics identical to a readdir Dirent (skipped, not followed).
80
+ *
81
+ * @param {string} linuxRoot - the workspace root, POSIX spelling.
82
+ * @param {string} linuxDir - the directory to list, POSIX spelling.
83
+ * @param {number} maxEntries - the listing cap (head -n bound).
84
+ * @returns {string[]} the agent exec argv.
85
+ */
86
+ export function traversalArgv(linuxRoot, linuxDir, maxEntries) {
87
+ const script = [
88
+ "root=$1 dir=$2 max=$3",
89
+ 'real=$(realpath -L -- "$dir") || exit 2',
90
+ 'case "$real" in "$root") ;; "$root"/*) ;; *) echo "OUTSIDE:$real" >&2; exit 3;; esac',
91
+ '[ -d "$real" ] || { echo "NOTDIR:$real" >&2; exit 4; }',
92
+ 'find "$real" -mindepth 1 -maxdepth 1 -printf \'%y\\t%f\\n\' | head -n "$max"',
93
+ ].join("\n");
94
+ return ["sh", "-c", script, "sh", linuxRoot, linuxDir, maxEntries];
95
+ }
96
+
97
+ /**
98
+ * Parse one `%y\t%f` listing into upstream's Dirent-like entries.
99
+ * `d` is a directory and `f` a file; every other type char — `l` for
100
+ * symlinks, devices, sockets — is skipped, matching a readdir Dirent.
101
+ *
102
+ * @param {string} stdout - the raw listing.
103
+ * @returns {{name: string, isDirectory: () => boolean, isFile: () => boolean}[]}
104
+ */
105
+ export function parseTraversalListing(stdout) {
106
+ const entries = [];
107
+ for (const line of String(stdout).split("\n")) {
108
+ const tab = line.indexOf("\t");
109
+ if (tab <= 0) continue;
110
+ const kind = line.slice(0, tab);
111
+ const name = line.slice(tab + 1);
112
+ if (name.length === 0) continue;
113
+ if (kind !== "d" && kind !== "f") continue;
114
+ entries.push({
115
+ name,
116
+ isDirectory: () => kind === "d",
117
+ isFile: () => kind === "f",
118
+ });
119
+ }
120
+ return entries;
121
+ }
122
+
123
+ /**
124
+ * The argv for the per-segment walk behind `resolveDisplayDirectory`: each
125
+ * segment must exist, be a directory, and not be a symlink — upstream's
126
+ * lstat verdicts, checked in one exec. Segments ride as argv elements, never
127
+ * interpolated into the script.
128
+ *
129
+ * @param {string} linuxRoot - the workspace root, POSIX spelling.
130
+ * @param {string[]} segments - the normalized display segments below the root.
131
+ * @returns {string[]} the agent exec argv.
132
+ */
133
+ export function resolveDirectoryArgv(linuxRoot, segments) {
134
+ const script = [
135
+ "root=$1",
136
+ 'cur="$root"',
137
+ 'for seg in "${@:2}"; do',
138
+ ' cur="$cur/$seg"',
139
+ ' if [ -L "$cur" ]; then echo "SYMLINK:$cur" >&2; exit 5; fi',
140
+ ' [ -d "$cur" ] || { echo "NOTDIR:$cur" >&2; exit 4; }',
141
+ "done",
142
+ 'printf \'%s\\n\' "$cur"',
143
+ ].join("\n");
144
+ return ["sh", "-c", script, "sh", linuxRoot, ...segments];
145
+ }
146
+
147
+ /**
148
+ * The host-default traversal — the node:fs implementations the seam keeps
149
+ * as upstream's defaults, replicated here so the prototype answers host
150
+ * workspaces without the seam and so tests can inject substitutes for it.
151
+ * (Upstream owns the real defaults; this copy exists only until the takeover.)
152
+ *
153
+ * @param {object} deps - the node:fs/promises functions, injectable for tests.
154
+ * @returns the strategy object with the upstream default semantics.
155
+ */
156
+ export function hostFileReferenceTraversal(deps) {
157
+ const read = async (absolute, signal) => {
158
+ signal.throwIfAborted();
159
+ try {
160
+ const entries = await deps.readdir(absolute, { withFileTypes: true });
161
+ signal.throwIfAborted();
162
+ return entries;
163
+ } catch (error) {
164
+ signal.throwIfAborted();
165
+ throw error;
166
+ }
167
+ };
168
+ return {
169
+ readWorkspaceRoot: read,
170
+ readDirectory: async (absolute, signal) => {
171
+ signal.throwIfAborted();
172
+ try {
173
+ const entries = await deps.readdir(absolute, { withFileTypes: true });
174
+ signal.throwIfAborted();
175
+ return entries;
176
+ } catch {
177
+ signal.throwIfAborted();
178
+ return [];
179
+ }
180
+ },
181
+ resolveDisplayDirectory: async (root, displayDirectory, signal) => {
182
+ const segments = normalizeDisplaySegments(displayDirectory);
183
+ if (segments === undefined) return undefined;
184
+ let current = root;
185
+ for (const segment of segments) {
186
+ signal.throwIfAborted();
187
+ current = `${current}${current.endsWith("/") ? "" : "/"}${segment}`;
188
+ try {
189
+ const status = await deps.lstat(current);
190
+ signal.throwIfAborted();
191
+ if (status.isSymbolicLink() || !status.isDirectory()) return undefined;
192
+ } catch (error) {
193
+ signal.throwIfAborted();
194
+ return undefined;
195
+ }
196
+ }
197
+ return current;
198
+ },
199
+ };
200
+ }
201
+
202
+ /**
203
+ * The distro-routed traversal strategy. Each method routes by coordinate:
204
+ * a `\\wsl.localhost\<distro>` UNC rides the resident agent (the search
205
+ * root and every absolute upstream passes stay in the UNC form it joined
206
+ * them with); anything else defers to the default traversal. The search
207
+ * root doubles as the containment root for the distro listings' guard, so
208
+ * the seam wires one strategy per `WorkspaceFileSearch` — exactly where
209
+ * `WorkspaceFileSearch(root, config, traversal)` would take it.
210
+ *
211
+ * @param {object} options
212
+ * @param {string} options.root - the search root, the UNC form upstream passes.
213
+ * @param {(distro: string) => object} options.runnerFor - the agent runner
214
+ * for one distro; tests substitute a fake (a real wiring passes sharedAgent).
215
+ * @param {object} options.defaultTraversal - the host-default strategy.
216
+ * @param {number} [options.maxEntries] - the listing cap, upstream's index budget.
217
+ * @returns the strategy object the seam's `WorkspaceFileSearch` would take.
218
+ */
219
+ export function wslFileReferenceTraversal({ root, runnerFor, defaultTraversal, maxEntries = DEFAULT_MAX_ENTRIES }) {
220
+ if (!isWslUnc(root)) return defaultTraversal;
221
+ const rootCoords = uncToPosix(root);
222
+ const rootLinux = rootCoords.linuxPath;
223
+
224
+ const listDistro = async (absolute, signal, onRefusal) => {
225
+ signal.throwIfAborted();
226
+ const { linuxPath } = uncToPosix(absolute);
227
+ try {
228
+ const result = await runnerFor(rootCoords.distro).exec({
229
+ cwd: "/",
230
+ argv: traversalArgv(rootLinux, linuxPath, maxEntries),
231
+ maxOutputBytes: 4_000_000,
232
+ timeoutMs: 30_000,
233
+ signal,
234
+ });
235
+ signal.throwIfAborted();
236
+ if (result.exitCode !== 0) {
237
+ return onRefusal(new Error(`listing "${linuxPath}" failed: ${String(result.stderr).trim() || `exit ${result.exitCode}`}`));
238
+ }
239
+ return parseTraversalListing(result.stdout);
240
+ } catch (error) {
241
+ signal.throwIfAborted();
242
+ return onRefusal(error);
243
+ }
244
+ };
245
+
246
+ return {
247
+ readWorkspaceRoot(absolute, signal) {
248
+ if (!isWslUnc(absolute)) return defaultTraversal.readWorkspaceRoot(absolute, signal);
249
+ return listDistro(absolute, signal, (error) => {
250
+ throw error;
251
+ });
252
+ },
253
+
254
+ readDirectory(absolute, signal) {
255
+ if (!isWslUnc(absolute)) return defaultTraversal.readDirectory(absolute, signal);
256
+ return listDistro(absolute, signal, () => []);
257
+ },
258
+
259
+ async resolveDisplayDirectory(rootOf, displayDirectory, signal) {
260
+ signal.throwIfAborted();
261
+ if (!isWslUnc(rootOf)) return defaultTraversal.resolveDisplayDirectory(rootOf, displayDirectory, signal);
262
+ const segments = normalizeDisplaySegments(displayDirectory);
263
+ if (segments === undefined) return undefined;
264
+ if (segments.length === 0) return root;
265
+ try {
266
+ const result = await runnerFor(rootCoords.distro).exec({
267
+ cwd: "/",
268
+ argv: resolveDirectoryArgv(rootLinux, segments),
269
+ maxOutputBytes: 4_000_000,
270
+ timeoutMs: 30_000,
271
+ signal,
272
+ });
273
+ signal.throwIfAborted();
274
+ if (result.exitCode !== 0) return undefined;
275
+ return posixToUnc(rootCoords.distro, result.stdout.trim());
276
+ } catch {
277
+ signal.throwIfAborted();
278
+ return undefined;
279
+ }
280
+ },
281
+ };
282
+ }
283
+
284
+ export default wslFileReferenceTraversal;
@@ -79,37 +79,33 @@ export function workspaceWriteDenial(displayPath) {
79
79
  }
80
80
 
81
81
  /**
82
- * The write/edit guard: compare the intent (`expected`) with what is on disk
83
- * NOW (`existing`, a stat row or `undefined`) and refuse the stale or
84
- * unobserved intent. The peer's dialect, byte for byte.
85
- * @param {object|undefined} expected - the write intent (`replaceIfVersion`
86
- * with a `version`, or `createIfAbsent`), or `undefined` for an unguarded write.
87
- * @param {{version: string}|undefined} existing - what stat found, or undefined.
88
- * @param {string} displayPath - the caller-facing path for the message.
89
- * @returns {{message: string, code: string}|null} the refusal, or null to proceed.
82
+ * The substrate configuration decision: the Windows-side share no longer
83
+ * serves the file tools. Every distro read and write runs on the resident
84
+ * agent, so no model-facing I/O crosses the 9p share at all — the share
85
+ * backend it replaces was the one path that still could. The provider throws
86
+ * this at construction time, before any session forms, so a profile carrying
87
+ * the old value fails loudly with the migration instead of silently serving
88
+ * I/O the configuration did not mean to allow.
89
+ * @param {string} substrate - the configured substrate value.
90
+ * @returns {{message: string}|null} the refusal, or null to proceed.
90
91
  */
91
- export function guardRefusal(expected, existing, displayPath) {
92
- if (expected === undefined) return null;
93
- if (expected.kind === "replaceIfVersion") {
94
- if (existing === undefined) return refusal(`cannot write "${displayPath}": file no longer exists`, "FS_STALE_VERSION");
95
- if (existing.version !== expected.version) {
96
- return refusal(`cannot write "${displayPath}": file changed since it was read`, "FS_STALE_VERSION");
97
- }
98
- return null;
99
- }
100
- if (expected.kind === "createIfAbsent" && existing !== undefined) {
101
- return refusal(`cannot overwrite existing "${displayPath}" without reading it first`, "FS_NOT_OBSERVED");
102
- }
103
- return null;
92
+ export function shareSubstrateRefusal(substrate) {
93
+ if (substrate !== "share") return null;
94
+ return {
95
+ message:
96
+ 'wsl-fs: substrate "share" is no longer available — the resident agent serves every ' +
97
+ "distro read and write, and no file tool crosses the 9p share. Delete the substrate " +
98
+ 'line from the provider config, or set it to "agent".',
99
+ };
104
100
  }
105
101
 
106
102
  /**
107
103
  * The substrate failure mapping: what the tool layer should surface for an
108
104
  * error that crossed the `ctx.fs` seam. A coded refusal keeps its code and
109
105
  * message byte-for-byte; anything else is the substrate being UNAVAILABLE —
110
- * the share backend's "distro is gone" case wearing the agent substrate's
111
- * wording — and the message names the documented opt-out, so an operator hit
112
- * by the fail-closed posture can reach the old behaviour without a doc trip.
106
+ * the resident agent that serves every distro read and write is out — and the
107
+ * message names the recovery path, because there is no fallback I/O for it to
108
+ * point at.
113
109
  * @param {unknown} error - what the substrate raised.
114
110
  * @returns {{message: string, code: string, cause: unknown}} the refusal the
115
111
  * provider wraps in `FsError`.
@@ -120,7 +116,8 @@ export function substrateFailure(error) {
120
116
  return {
121
117
  message:
122
118
  `the distro file substrate is unavailable: ${message}. ` +
123
- `Set "substrate: \\"share\\"" in the provider config to work on the Windows-side share while the agent is out.`,
119
+ `The resident agent serves every distro read and write; check the distro is running ` +
120
+ `("wsl.exe -l -v") and restart the session if it stays out.`,
124
121
  code: "FS_IO_ERROR",
125
122
  cause: error instanceof Error ? error : undefined,
126
123
  };
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The root-plane filesystem with coordinate routing: targets under a
3
+ * `\\wsl.localhost\<distro>` identity are served by the distro substrate
4
+ * (the resident agent, on ext4); every other target — drive-letter paths,
5
+ * the deployment cwd — is served by the host `LocalFileSystem` this class
6
+ * extends, unchanged.
7
+ *
8
+ * ## Why a router, and why here
9
+ *
10
+ * The root `ctx.fs` is session-less: it cannot be switched per session the
11
+ * way a preset's isolated `fs` can, so the two workspace kinds share one
12
+ * backend — and the stock backend serves the distro workspace across the 9p
13
+ * share, in its slow direction, with no usable change notifications. Routing
14
+ * by coordinate makes every root consumer (the workspace-files change feed
15
+ * among them) correct on both workspace kinds without per-consumer patches.
16
+ *
17
+ * The design note is `docs/root-fs-routing.md` (the operation routing table,
18
+ * the intended behavior differences, and the dormant-root-fence rationale).
19
+ *
20
+ * ## What is intentionally not here
21
+ *
22
+ * No write fence: the root plane has no write consumers (the model's writes
23
+ * ride the per-session preset filesystems, which enforce their own
24
+ * containment — the distro side kernel-enforced). If a root-plane write
25
+ * consumer appears, its fence rides with that consumer.
26
+ *
27
+ * @module dsh-plugin-wsl/fs-routing
28
+ */
29
+
30
+ import { isWslUnc, uncToPosix, windowsToLinuxMount } from "./paths.js";
31
+ import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
32
+ import { linuxJoin } from "./workspace-files-route.js";
33
+ import { WslFileSystem } from "./index.js";
34
+
35
+ /**
36
+ * Whether a resolved target identity belongs to the distro.
37
+ *
38
+ * @param targetKey - the target's identity (a UNC for distro paths).
39
+ * @returns true when the target lives inside a WSL distro.
40
+ */
41
+ export function targetIsDistro(targetKey) {
42
+ return isWslUnc(targetKey);
43
+ }
44
+
45
+ /**
46
+ * Whether a raw path-or-cwd input names the distro (used by the operations
47
+ * that take paths before a target exists: resolve, lstat).
48
+ *
49
+ * @param value - a path in any coordinate system, or undefined.
50
+ * @returns true when the input names a distro UNC.
51
+ */
52
+ export function inputIsDistro(value) {
53
+ return value !== undefined && isWslUnc(value);
54
+ }
55
+
56
+ /**
57
+ * The Linux working path for one request: UNC absolutes map onto their Linux
58
+ * paths, POSIX absolutes and relative paths join onto the root.
59
+ *
60
+ * @param linuxRoot - the workspace root in Linux form.
61
+ * @param path - the requested path, any coordinate system.
62
+ * @returns the absolute Linux path.
63
+ */
64
+ /**
65
+ * The root-plane filesystem with coordinate routing.
66
+ *
67
+ * The distro backend is built lazily on the first routed target, sharing this
68
+ * context (the root `sandboxPolicy` service provides the deployment default
69
+ * mode the distro side reports through `sandboxMode`).
70
+ */
71
+ export class WslRoutingFileSystem extends LocalFileSystem {
72
+ static Config = LocalFileSystem.Config;
73
+
74
+ /** The lazy distro backend, built on first routed use. */
75
+ #distro;
76
+ /** The distro knobs, verbatim from the row config. */
77
+ #distroConfig;
78
+
79
+ constructor(ctx, config) {
80
+ super(ctx, config);
81
+ this.#distroConfig = {
82
+ distro: config.distro ?? "",
83
+ wslPath: config.wslPath ?? "wsl.exe",
84
+ cwd: config.distroCwd ?? "",
85
+ diffBasisMaxBytes: config.diffBasisMaxBytes,
86
+ sandbox: config.sandbox ?? true,
87
+ maskWindowsDrive: config.maskWindowsDrive ?? false,
88
+ restrictToDistro: config.restrictToDistro ?? true,
89
+ watchMaxDepth: config.watchMaxDepth ?? 0,
90
+ };
91
+ }
92
+
93
+ /** The distro backend for routed targets, built on first use. */
94
+ distro() {
95
+ return (this.#distro ??= new WslFileSystem(this.ctx, this.#distroConfig));
96
+ }
97
+
98
+ async resolve(path, opts) {
99
+ if (inputIsDistro(path) || inputIsDistro(opts?.cwd)) return (await this.distro()).resolve(path, opts);
100
+ return super.resolve(path, opts);
101
+ }
102
+
103
+ async lstat(path, opts, signal) {
104
+ if (inputIsDistro(path) || inputIsDistro(opts?.cwd)) return (await this.distro()).lstat(path, opts, signal);
105
+ return super.lstat(path, opts, signal);
106
+ }
107
+
108
+ async stat(target, signal) {
109
+ if (targetIsDistro(target.targetKey)) return (await this.distro()).stat(target, signal);
110
+ return super.stat(target, signal);
111
+ }
112
+
113
+ async readText(target, signal) {
114
+ if (targetIsDistro(target.targetKey)) return (await this.distro()).readText(target, signal);
115
+ return super.readText(target, signal);
116
+ }
117
+
118
+ async streamText(target, signal) {
119
+ if (targetIsDistro(target.targetKey)) return (await this.distro()).streamText(target, signal);
120
+ return super.streamText(target, signal);
121
+ }
122
+
123
+ async readBytes(target, signal, maxBytes) {
124
+ if (targetIsDistro(target.targetKey)) return (await this.distro()).readBytes(target, signal, maxBytes);
125
+ return super.readBytes(target, signal, maxBytes);
126
+ }
127
+
128
+ async readByteRange(target, range, signal) {
129
+ if (targetIsDistro(target.targetKey)) return (await this.distro()).readByteRange(target, range, signal);
130
+ return super.readByteRange(target, range, signal);
131
+ }
132
+
133
+ async listDir(target, signal) {
134
+ if (targetIsDistro(target.targetKey)) return (await this.distro()).listDir(target, signal);
135
+ return super.listDir(target, signal);
136
+ }
137
+
138
+ async writeText(target, content, expected, signal, sandboxPolicy) {
139
+ if (targetIsDistro(target.targetKey)) {
140
+ return (await this.distro()).writeText(target, content, expected, signal, sandboxPolicy);
141
+ }
142
+ return super.writeText(target, content, expected, signal, sandboxPolicy);
143
+ }
144
+
145
+ async editText(target, edit, expected, signal, sandboxPolicy) {
146
+ if (targetIsDistro(target.targetKey)) {
147
+ return (await this.distro()).editText(target, edit, expected, signal, sandboxPolicy);
148
+ }
149
+ return super.editText(target, edit, expected, signal, sandboxPolicy);
150
+ }
151
+
152
+ async watch(target, changed, signal) {
153
+ if (targetIsDistro(target.targetKey)) return (await this.distro()).watch(target, changed, signal);
154
+ return super.watch(target, changed, signal);
155
+ }
156
+
157
+ contains(parent, child) {
158
+ if (targetIsDistro(parent.targetKey)) {
159
+ // Both targets came from this router's distro resolve, so the plain
160
+ // POSIX containment over their process paths is the same check the
161
+ // distro substrate performs for its own guarded writes.
162
+ const base = this.processPath(parent);
163
+ const full = this.processPath(child);
164
+ return full === base || full.startsWith(base.endsWith("/") ? base : `${base}/`);
165
+ }
166
+ return super.contains(parent, child);
167
+ }
168
+
169
+ processPath(target) {
170
+ const parsed = uncToPosix(target.targetKey);
171
+ if (parsed !== undefined) return parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
172
+ return super.processPath(target);
173
+ }
174
+ }
175
+
176
+ export default WslRoutingFileSystem;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Which git spawns belong inside the distro, and the `wsl.exe` argv they
3
+ * become there.
4
+ *
5
+ * `workspace-changes` snapshots a workspace's git state by spawning the host
6
+ * git through `ctx.subprocess` with the workspace as the working directory.
7
+ * On a distro workspace that spawn runs Windows git against the 9P share —
8
+ * the same slow direction as the search path, and the snapshot's
9
+ * `git add --all` is a full work-tree scan, the most metadata-heavy operation
10
+ * there is. The interception point is the same `spawn` override; the decision
11
+ * lives here, pure and unit-testable, mirroring `search-route.js`.
12
+ *
13
+ * ## What is routed, and what deliberately is not
14
+ *
15
+ * A spawn is routed only when the working directory names the distro (a
16
+ * `\\wsl.localhost\<distro>` UNC) and the argv carries none of the discovery
17
+ * flags (`--show-toplevel`, `--absolute-git-dir`, `--git-path`). Those three
18
+ * print **absolute paths**, which `workspace-changes` resolves against the
19
+ * UNC workspace (`git.ts` `resolve(cwd, line)`) — a distro git would answer
20
+ * in Linux paths and corrupt that resolution. Host git answers in UNC form,
21
+ * which is exactly what the caller wants, and the discovery call is one
22
+ * cheap command rather than a work-tree scan. Everything else — `add`,
23
+ * `write-tree`, `ls-tree`, `cat-file`, `diff-tree`, `ls-files`,
24
+ * `check-ignore` — is safe to route: snapshot calls run in an environment
25
+ * fully isolated from the user's git state (`GIT_INDEX_FILE` /
26
+ * `GIT_OBJECT_DIRECTORY` / `GIT_ALTERNATE_OBJECT_DIRECTORIES` pointing at the
27
+ * tool's private scratch), so the distro git mutates scratch, never the
28
+ * repository.
29
+ *
30
+ * ## Environment translation
31
+ *
32
+ * The routed spawn must forward the caller's `GIT_*` environment into the
33
+ * distro, and its values are paths in Windows form (`\\wsl.localhost\...`
34
+ * identities and drive-letter scratch dirs). `translateGitEnv` rewrites those
35
+ * two shapes into distro paths with the same pure translations the fs seam
36
+ * uses, forwards every other value verbatim (`GIT_CONFIG_COUNT=0` and
37
+ * friends), and returns the names that must ride `WSLENV`. One documented
38
+ * limit: a colon-separated multi-directory value (none of the current
39
+ * callers set one) passes through untranslated.
40
+ *
41
+ * @module dsh-plugin-wsl/git-route
42
+ */
43
+
44
+ import { isWslUnc, uncToPosix, windowsToLinuxMount } from "./paths.js";
45
+
46
+ /** The one program this rewrite passes through to the distro. */
47
+ const GIT_PROGRAM = "git";
48
+
49
+ /** Discovery flags print absolute paths — host git keeps that contract. */
50
+ const DISCOVERY_FLAGS = ["--show-toplevel", "--absolute-git-dir", "--git-path"];
51
+
52
+ /**
53
+ * Whether a spawn's program is git.
54
+ * @param unknown - the spawn's `argv[0]`.
55
+ * @returns true when the spawn is a git launch.
56
+ */
57
+ export function isGitProgram(program) {
58
+ if (typeof program !== "string" || program.length === 0) return false;
59
+ const base = program.slice(Math.max(program.lastIndexOf("/"), program.lastIndexOf("\\")) + 1);
60
+ return base.toLowerCase().replace(/\.exe$/u, "") === GIT_PROGRAM;
61
+ }
62
+
63
+ /**
64
+ * Whether a git argv carries the discovery flags whose absolute output the
65
+ * caller resolves against the UNC world.
66
+ * @param {unknown[]} argv - the spawn's argv; `argv[0]` is the program.
67
+ * @returns true when the spawn must stay on host git.
68
+ */
69
+ function namesDiscoveryPaths(argv) {
70
+ return argv.some((arg) => typeof arg === "string" && DISCOVERY_FLAGS.includes(arg));
71
+ }
72
+
73
+ /**
74
+ * Translate one `GIT_*` value into the distro's coordinate system.
75
+ *
76
+ * UNC identities (`\\wsl.localhost\<distro>\...`) map onto their Linux paths;
77
+ * drive-letter paths map onto their `/mnt/<drive>` mounts; anything else is
78
+ * not a path we can name and passes through verbatim.
79
+ *
80
+ * @param value - the env value.
81
+ * @returns the distro-side spelling of the value.
82
+ */
83
+ export function translateGitEnvValue(value) {
84
+ if (typeof value !== "string") return value;
85
+ if (isWslUnc(value)) {
86
+ const parsed = uncToPosix(value);
87
+ return parsed === undefined ? value : parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
88
+ }
89
+ const mounted = windowsToLinuxMount(value);
90
+ return mounted === undefined ? value : mounted;
91
+ }
92
+
93
+ /**
94
+ * Translate a git spawn's environment for the distro and name the keys that
95
+ * must ride `WSLENV`.
96
+ *
97
+ * @param env - the spawn's environment, or undefined.
98
+ * @returns the environment to forward (every key, path-shaped values
99
+ * translated) and the `WSLENV` names.
100
+ */
101
+ export function translateGitEnv(env) {
102
+ const translated = {};
103
+ const names = [];
104
+ for (const [name, value] of Object.entries(env ?? {})) {
105
+ translated[name] = name.startsWith("GIT_") ? translateGitEnvValue(value) : value;
106
+ names.push(name);
107
+ }
108
+ return { env: translated, wslenv: names };
109
+ }
110
+
111
+ /**
112
+ * The distro-side rewrite of one git spawn, when it belongs in the distro.
113
+ *
114
+ * `gitArgv` is the distro command itself; `wslArgv` is the same command
115
+ * wrapped for a one-shot `wsl.exe --exec` launch — git snapshot operations
116
+ * are not latency-sensitive, so the routing stays on the simple transport
117
+ * and does not need the resident agent. `wsl.exe` itself must start in a
118
+ * Windows directory, so the caller supplies its own host cwd.
119
+ *
120
+ * @param object - the spawn's inputs.
121
+ * @param {unknown[]} object.argv - the spawn's argv; `argv[0]` is the program.
122
+ * @param {unknown} object.cwd - the spawn's working directory, any coordinate system.
123
+ * @param {string} object.wslPath - path to `wsl.exe`.
124
+ * @returns {{distro: string, linuxCwd: string, gitArgv: string[], wslArgv: string[]}|undefined} the
125
+ * rewrite, or undefined when the spawn is not a routable distro git launch
126
+ * (non-git programs, host directories, and the absolute-output discovery
127
+ * flags all keep the shipped spawn).
128
+ */
129
+ export function gitSpawnRewrite({ argv, cwd, wslPath }) {
130
+ if (!Array.isArray(argv) || !isGitProgram(argv[0])) return undefined;
131
+ if (namesDiscoveryPaths(argv)) return undefined;
132
+ const named = uncToPosix(cwd);
133
+ if (named === undefined) return undefined;
134
+ const linuxCwd = named.linuxPath.length > 0 ? named.linuxPath : "/";
135
+ const gitArgv = [GIT_PROGRAM, ...argv.slice(1)];
136
+ return {
137
+ distro: named.distro,
138
+ linuxCwd,
139
+ gitArgv,
140
+ wslArgv: [wslPath, "-d", named.distro, "--cd", linuxCwd, "--exec", ...gitArgv],
141
+ };
142
+ }