dsh-plugin-wsl-env 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +144 -15
  2. package/CONTRIBUTING.md +25 -33
  3. package/README.md +10 -10
  4. package/SECURITY.md +6 -4
  5. package/SUPPORT.md +8 -8
  6. package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
  7. package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
  8. package/agent/wsl-agent.sh +51 -12
  9. package/cordis.patch.yml +23 -10
  10. package/docs/ARCHITECTURE.md +56 -41
  11. package/docs/COMPATIBILITY.md +121 -0
  12. package/docs/CONFIGURATION.md +275 -273
  13. package/docs/LIMITATIONS.md +114 -38
  14. package/docs/MIGRATION.md +16 -13
  15. package/docs/PARITY.md +4 -3
  16. package/docs/PERFORMANCE.md +11 -9
  17. package/docs/QUICKSTART.md +46 -21
  18. package/docs/README.zh.md +7 -7
  19. package/docs/RELEASING.md +257 -0
  20. package/docs/REQUIREMENTS.zh.md +77 -0
  21. package/docs/ROADMAP-1.0.md +105 -61
  22. package/docs/ROADMAP.md +15 -9
  23. package/docs/UPSTREAM-FILE-REFERENCE-SEAM.md +3 -3
  24. package/docs/UPSTREAM-FSIO-EXPORT.md +7 -7
  25. package/docs/UPSTREAM-OPEN-IN-APP.md +57 -0
  26. package/docs/UPSTREAM-SESSION-NOTICE.md +4 -4
  27. package/docs/UPSTREAM-TERMINAL-TITLE.md +10 -12
  28. package/docs/VALUE.zh.md +144 -0
  29. package/docs/adr/0002-native-agent.md +348 -0
  30. package/docs/root-fs-routing.md +186 -0
  31. package/docs/screenshots/picker.png +0 -0
  32. package/docs/screenshots/preview.png +0 -0
  33. package/docs/screenshots/sandbox.png +0 -0
  34. package/docs/screenshots/terminal.png +0 -0
  35. package/lib/agent-exec.d.ts +7 -3
  36. package/lib/agent-protocol.d.ts +18 -5
  37. package/lib/agent-protocol.js +11 -6
  38. package/lib/agent-shared.js +1 -0
  39. package/lib/agent.d.ts +28 -9
  40. package/lib/agent.js +74 -14
  41. package/lib/fs-routing.d.ts +2 -1
  42. package/lib/fs-routing.js +13 -1
  43. package/lib/fs-substrate.d.ts +11 -9
  44. package/lib/fs-substrate.js +6 -9
  45. package/lib/fsio-agent.d.ts +2 -3
  46. package/lib/fsio-agent.js +2 -3
  47. package/lib/git-exec.d.ts +117 -0
  48. package/lib/git-exec.js +105 -0
  49. package/lib/git-route.d.ts +43 -13
  50. package/lib/git-route.js +54 -15
  51. package/lib/index.d.ts +20 -13
  52. package/lib/index.js +18 -11
  53. package/lib/session-runtime.d.ts +5 -3
  54. package/lib/session-runtime.js +5 -4
  55. package/lib/subprocess.d.ts +21 -7
  56. package/lib/subprocess.js +63 -9
  57. package/locale/en.json +1 -1
  58. package/locale/zh.json +1 -1
  59. package/package.json +13 -4
  60. package/scripts/diagnose.sh +1 -1
  61. package/scripts/doctor.mjs +23 -3
package/lib/fs-routing.js CHANGED
@@ -31,6 +31,7 @@
31
31
  * @module dsh-plugin-wsl/fs-routing
32
32
  */
33
33
  import { isWslUnc, uncToPosix } from "./paths.js";
34
+ import z from "@deepseek-ai/schemastery";
34
35
  import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
35
36
  import { WslFileSystem } from "./index.js";
36
37
  /**
@@ -101,7 +102,18 @@ export function inputIsDistro(value) {
101
102
  * mode the distro side reports through `sandboxMode`).
102
103
  */
103
104
  export class WslRoutingFileSystem extends LocalFileSystem {
104
- static Config = LocalFileSystem.Config;
105
+ static Config = z.intersect([
106
+ LocalFileSystem.Config,
107
+ z.object({
108
+ distro: z.string().default(""),
109
+ wslPath: z.string().default("wsl.exe"),
110
+ distroCwd: z.string().default(""),
111
+ sandbox: z.boolean().default(true),
112
+ maskWindowsDrive: z.boolean().default(false),
113
+ restrictToDistro: z.boolean().default(true),
114
+ watchMaxDepth: z.natural().default(0),
115
+ }),
116
+ ]);
105
117
  /**
106
118
  * The routed backend's constructor reads the deployment default mode from
107
119
  * `ctx.sandboxPolicy`, exactly as the shipped `fs-sandbox` row this class
@@ -13,12 +13,9 @@
13
13
  * create publishes with the distro-side no-replace link, a real atomic
14
14
  * primitive that refuses to replace. An overwrite or edit carries the
15
15
  * version it was based on INTO the write op, and the agent re-verifies it
16
- * with a fresh stat one syscall before the rename: a concurrent writer
17
- * (an editor save, `git stash`, another session) wins, and the stale
18
- * write refuses with `FS_STALE_VERSION` instead of clobbering. That
19
- * narrows the share backend's check-then-write window from a host round
20
- * trip to kernel-adjacent — the honest wording; "closed" was true only
21
- * for the create case.
16
+ * with a fresh stat before the rename: an earlier conflicting write is
17
+ * refused with `FS_STALE_VERSION`. A writer between that stat and the
18
+ * rename can still be overwritten; only guarded create is atomic.
22
19
  * 2. A symlink needs no retry machinery: every identity is resolved where the
23
20
  * symlinks live.
24
21
  *
@@ -59,6 +56,7 @@ export declare class AgentSubstrate {
59
56
  agentFor: ((policy: {
60
57
  mode: string;
61
58
  workspaceRoot: string;
59
+ sessionId?: string;
62
60
  } | undefined) => Promise<WslAgent>) | undefined;
63
61
  /** The distro-side filesystem face. */
64
62
  fs: DistroFs;
@@ -83,6 +81,7 @@ export declare class AgentSubstrate {
83
81
  agentFor?: (policy: {
84
82
  mode: string;
85
83
  workspaceRoot: string;
84
+ sessionId?: string;
86
85
  } | undefined) => Promise<WslAgent>;
87
86
  });
88
87
  /**
@@ -95,6 +94,7 @@ export declare class AgentSubstrate {
95
94
  mutationAgent(policy: {
96
95
  mode: string;
97
96
  workspaceRoot: string;
97
+ sessionId?: string;
98
98
  } | undefined): Promise<WslAgent>;
99
99
  /**
100
100
  * Run `op` with exclusive access to `targetKey` (FIFO per key).
@@ -204,7 +204,7 @@ export declare class AgentSubstrate {
204
204
  * guard, best-effort diff basis, atomic publication, outcome shape. The
205
205
  * guard is FORWARDED — a guarded create publishes with the distro-side
206
206
  * no-replace link (atomic), and a `replaceIfVersion` write carries its
207
- * version into the op, where the agent re-verifies it one syscall before
207
+ * version into the op, where the agent re-verifies it before
208
208
  * the rename and refuses with `FS_STALE_VERSION` on a mismatch.
209
209
  *
210
210
  * @param target - the resolved target.
@@ -220,6 +220,7 @@ export declare class AgentSubstrate {
220
220
  } | undefined, signal?: AbortSignal, policy?: {
221
221
  mode: string;
222
222
  workspaceRoot: string;
223
+ sessionId?: string;
223
224
  } | undefined): Promise<{
224
225
  operation: string;
225
226
  version: string;
@@ -230,8 +231,8 @@ export declare class AgentSubstrate {
230
231
  * Edit a text file with the host backend's orchestration: probe, version
231
232
  * guard, LF-normalized read, literal replacement, line-ending restore,
232
233
  * atomic publication, outcome shape. The version the edit was based on
233
- * rides the write op and is re-verified distro-side one syscall before the
234
- * rename, so a concurrent writer wins instead of being clobbered.
234
+ * rides the write op and is re-verified distro-side before the
235
+ * rename, narrowing but not eliminating the concurrent-write race.
235
236
  *
236
237
  * @param target - the resolved target.
237
238
  * @param edit - the literal request.
@@ -249,6 +250,7 @@ export declare class AgentSubstrate {
249
250
  } | undefined, signal?: AbortSignal, policy?: {
250
251
  mode: string;
251
252
  workspaceRoot: string;
253
+ sessionId?: string;
252
254
  } | undefined): Promise<{
253
255
  version: string;
254
256
  before: string;
@@ -13,12 +13,9 @@
13
13
  * create publishes with the distro-side no-replace link, a real atomic
14
14
  * primitive that refuses to replace. An overwrite or edit carries the
15
15
  * version it was based on INTO the write op, and the agent re-verifies it
16
- * with a fresh stat one syscall before the rename: a concurrent writer
17
- * (an editor save, `git stash`, another session) wins, and the stale
18
- * write refuses with `FS_STALE_VERSION` instead of clobbering. That
19
- * narrows the share backend's check-then-write window from a host round
20
- * trip to kernel-adjacent — the honest wording; "closed" was true only
21
- * for the create case.
16
+ * with a fresh stat before the rename: an earlier conflicting write is
17
+ * refused with `FS_STALE_VERSION`. A writer between that stat and the
18
+ * rename can still be overwritten; only guarded create is atomic.
22
19
  * 2. A symlink needs no retry machinery: every identity is resolved where the
23
20
  * symlinks live.
24
21
  *
@@ -196,7 +193,7 @@ export class AgentSubstrate {
196
193
  * guard, best-effort diff basis, atomic publication, outcome shape. The
197
194
  * guard is FORWARDED — a guarded create publishes with the distro-side
198
195
  * no-replace link (atomic), and a `replaceIfVersion` write carries its
199
- * version into the op, where the agent re-verifies it one syscall before
196
+ * version into the op, where the agent re-verifies it before
200
197
  * the rename and refuses with `FS_STALE_VERSION` on a mismatch.
201
198
  *
202
199
  * @param target - the resolved target.
@@ -247,8 +244,8 @@ export class AgentSubstrate {
247
244
  * Edit a text file with the host backend's orchestration: probe, version
248
245
  * guard, LF-normalized read, literal replacement, line-ending restore,
249
246
  * atomic publication, outcome shape. The version the edit was based on
250
- * rides the write op and is re-verified distro-side one syscall before the
251
- * rename, so a concurrent writer wins instead of being clobbered.
247
+ * rides the write op and is re-verified distro-side before the
248
+ * rename, narrowing but not eliminating the concurrent-write race.
252
249
  *
253
250
  * @param target - the resolved target.
254
251
  * @param edit - the literal request.
@@ -244,9 +244,8 @@ export declare class DistroFs {
244
244
  * Publish `content` over `linuxPath` the POSIX way: the agent stages into a
245
245
  * private 0700 sibling, fsyncs, chmods, and renames — or hard links for a
246
246
  * guarded create. A `replace` publish carrying `expectedVersion` is
247
- * re-verified by the agent against a fresh stat one syscall before the
248
- * rename: a concurrent writer (an editor save, a `git stash`) wins, and the
249
- * stale write refuses with `FS_STALE_VERSION` instead of landing.
247
+ * re-verified by the agent against a fresh stat before the rename. A change
248
+ * after that check can still be overwritten by the rename.
250
249
  *
251
250
  * @param linuxPath - the target path inside the distro.
252
251
  * @param content - the full new content.
package/lib/fsio-agent.js CHANGED
@@ -407,9 +407,8 @@ export class DistroFs {
407
407
  * Publish `content` over `linuxPath` the POSIX way: the agent stages into a
408
408
  * private 0700 sibling, fsyncs, chmods, and renames — or hard links for a
409
409
  * guarded create. A `replace` publish carrying `expectedVersion` is
410
- * re-verified by the agent against a fresh stat one syscall before the
411
- * rename: a concurrent writer (an editor save, a `git stash`) wins, and the
412
- * stale write refuses with `FS_STALE_VERSION` instead of landing.
410
+ * re-verified by the agent against a fresh stat before the rename. A change
411
+ * after that check can still be overwritten by the rename.
413
412
  *
414
413
  * @param linuxPath - the target path inside the distro.
415
414
  * @param content - the full new content.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The agent-backed execution handle for the git-snapshot spawn.
3
+ *
4
+ * `workspace-changes`' `GitRunner` consumes a small slice of the subprocess
5
+ * handle: `handle.done` for `{exitCode, signal}`, then
6
+ * `handle.collected.stdout/stderr.readFrom(0)` for `{text, lossy}` — with
7
+ * per-stream caps applied by the caller's `stdio` spec (a generous stdout
8
+ * budget and a 16 KiB stderr tail). This module builds exactly that surface
9
+ * around one resident-agent `exec`, so a distro snapshot pays the warm agent's
10
+ * ~2 ms round trip per git call instead of the one-shot `wsl.exe`'s ~230 ms
11
+ * process startup — measured 2026-11-01 on `ubuntu`; a full snapshot's 6-8
12
+ * calls (plus per-file `cat-file`) used to spend over a second inside
13
+ * `wsl.exe` alone.
14
+ *
15
+ * The caps map onto the agent's single per-request capture ceiling as the
16
+ * larger of the two stream budgets; the reader then slices each stream to its
17
+ * own budget and reports `lossy` from that slice, so the caller's contract
18
+ * (per-stream cut flags) holds even though the wire carries one cap.
19
+ *
20
+ * Environment rides the agent's per-request `env` (an `env --` argv prefix —
21
+ * no `SETENV` global pollution), holding only the translated `GIT_*` names:
22
+ * the one-shot path forwards exactly those through `WSLENV`, and forwarding a
23
+ * host `PATH` into the distro would be wrong, not just different.
24
+ *
25
+ * The agent being out is the fallback cue, exactly as `search-exec.ts`:
26
+ * `spawnFallback` (the caller's one-shot `wsl.exe` handle) is consulted
27
+ * lazily inside `done`, and from that point the facade proxies the delegate
28
+ * verbatim. Cancellation shares one channel: the caller's `signal` and
29
+ * `kill()` abort the agent exec, settling `done` as killed.
30
+ *
31
+ * Pure shaping over injected collaborators, unit-testable without a distro.
32
+ *
33
+ * This is a TypeScript source built to `lib/git-exec.js`; edit THIS file and
34
+ * run `pnpm run build` — the artifact under `lib/` is generated, and
35
+ * `pnpm test` fails when it drifts.
36
+ *
37
+ * @module dsh-plugin-wsl/git-exec
38
+ */
39
+ import type { AgentExecRunner } from "./agent-exec.js";
40
+ /** One collected-stream reader with the seam's standard shape. */
41
+ export interface GitStreamReader {
42
+ /** Read the stream from a byte offset; `lossy` says the cap cut it. */
43
+ readFrom: (from: number) => {
44
+ text: string;
45
+ lossy: boolean;
46
+ nextOffset: number;
47
+ };
48
+ }
49
+ /**
50
+ * The slice of the subprocess handle `GitRunner` consumes: settle on `done`,
51
+ * then read the collected streams.
52
+ */
53
+ export interface GitExecutionHandle {
54
+ /** Settles with the exit code and signal; rejects on a genuine failure. */
55
+ done: Promise<{
56
+ exitCode: number | null;
57
+ signal: string | null;
58
+ }>;
59
+ /** The collected stream readers; a delegated handle's own on fallback. */
60
+ collected: {
61
+ stdout?: GitStreamReader | undefined;
62
+ stderr?: GitStreamReader | undefined;
63
+ };
64
+ /** Cooperative kill; the runner relies on its `signal` for cancellation. */
65
+ kill?: () => boolean | void;
66
+ }
67
+ /** Per-stream capture budgets the caller declared on its `stdio` spec. */
68
+ export interface GitStreamCaps {
69
+ /** The stdout budget in bytes; the snapshot's `outputMaxBytes`. */
70
+ stdout: number;
71
+ /** The stderr tail budget in bytes. */
72
+ stderr: number;
73
+ }
74
+ /**
75
+ * Run one git command through the agent and present it as a git-snapshot
76
+ * handle.
77
+ *
78
+ * @param object - the handle's collaborators.
79
+ * @returns the snapshot handle (`done`, `collected`, `kill`).
80
+ */
81
+ export declare function gitExecutionHandle({ agent, cwd, argv, env, stdin, caps, distro, discovery, signal, spawnFallback }: {
82
+ /** The {@link WslAgent}-like runner; throws `AgentUnavailableError` when out. */
83
+ agent: AgentExecRunner;
84
+ /** The Linux working directory to snapshot from. */
85
+ cwd: string;
86
+ /** The distro-side argv (`git` and its arguments). */
87
+ argv: string[];
88
+ /**
89
+ * The distro name; required when `discovery` is set (absolute path lines
90
+ * become `\\wsl.localhost\<distro><path>`).
91
+ */
92
+ distro?: string;
93
+ /**
94
+ * True for a SENTINEL discovery call (`--show-toplevel` /
95
+ * `--absolute-git-dir` / `--git-path` answered by the distro git): the
96
+ * captured stdout's absolute Linux path lines are translated into UNC form
97
+ * before the caller reads them. Snapshot calls leave this unset.
98
+ */
99
+ discovery?: boolean;
100
+ /**
101
+ * Per-request environment: the caller's entries with path-shaped values
102
+ * translated into distro form. Empty entries are dropped; nothing but
103
+ * `GIT_*` names should reach here.
104
+ */
105
+ env: Record<string, string>;
106
+ /** The child's entire stdin, or undefined for an empty one. */
107
+ stdin?: Uint8Array;
108
+ /** Per-stream capture budgets from the caller's `stdio` spec. */
109
+ caps: GitStreamCaps;
110
+ /** The caller's cancellation (its own timeout races in through `AbortSignal.any`). */
111
+ signal?: AbortSignal;
112
+ /**
113
+ * Builds the one-shot handle the facade delegates to when the agent is out;
114
+ * called at most once, lazily.
115
+ */
116
+ spawnFallback: () => GitExecutionHandle;
117
+ }): GitExecutionHandle;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The agent-backed execution handle for the git-snapshot spawn.
3
+ *
4
+ * `workspace-changes`' `GitRunner` consumes a small slice of the subprocess
5
+ * handle: `handle.done` for `{exitCode, signal}`, then
6
+ * `handle.collected.stdout/stderr.readFrom(0)` for `{text, lossy}` — with
7
+ * per-stream caps applied by the caller's `stdio` spec (a generous stdout
8
+ * budget and a 16 KiB stderr tail). This module builds exactly that surface
9
+ * around one resident-agent `exec`, so a distro snapshot pays the warm agent's
10
+ * ~2 ms round trip per git call instead of the one-shot `wsl.exe`'s ~230 ms
11
+ * process startup — measured 2026-11-01 on `ubuntu`; a full snapshot's 6-8
12
+ * calls (plus per-file `cat-file`) used to spend over a second inside
13
+ * `wsl.exe` alone.
14
+ *
15
+ * The caps map onto the agent's single per-request capture ceiling as the
16
+ * larger of the two stream budgets; the reader then slices each stream to its
17
+ * own budget and reports `lossy` from that slice, so the caller's contract
18
+ * (per-stream cut flags) holds even though the wire carries one cap.
19
+ *
20
+ * Environment rides the agent's per-request `env` (an `env --` argv prefix —
21
+ * no `SETENV` global pollution), holding only the translated `GIT_*` names:
22
+ * the one-shot path forwards exactly those through `WSLENV`, and forwarding a
23
+ * host `PATH` into the distro would be wrong, not just different.
24
+ *
25
+ * The agent being out is the fallback cue, exactly as `search-exec.ts`:
26
+ * `spawnFallback` (the caller's one-shot `wsl.exe` handle) is consulted
27
+ * lazily inside `done`, and from that point the facade proxies the delegate
28
+ * verbatim. Cancellation shares one channel: the caller's `signal` and
29
+ * `kill()` abort the agent exec, settling `done` as killed.
30
+ *
31
+ * Pure shaping over injected collaborators, unit-testable without a distro.
32
+ *
33
+ * This is a TypeScript source built to `lib/git-exec.js`; edit THIS file and
34
+ * run `pnpm run build` — the artifact under `lib/` is generated, and
35
+ * `pnpm test` fails when it drifts.
36
+ *
37
+ * @module dsh-plugin-wsl/git-exec
38
+ */
39
+ import { AgentUnavailableError } from "./agent-errors.js";
40
+ import { translateDiscoveryOutput } from "./git-route.js";
41
+ /**
42
+ * Run one git command through the agent and present it as a git-snapshot
43
+ * handle.
44
+ *
45
+ * @param object - the handle's collaborators.
46
+ * @returns the snapshot handle (`done`, `collected`, `kill`).
47
+ */
48
+ export function gitExecutionHandle({ agent, cwd, argv, env, stdin, caps, distro, discovery, signal, spawnFallback }) {
49
+ const state = { delegate: undefined, stdout: undefined, stderr: undefined };
50
+ const controller = new AbortController();
51
+ if (signal?.aborted)
52
+ controller.abort(signal.reason);
53
+ else
54
+ signal?.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
55
+ // The agent enforces ONE cap per request; declare the larger budget on the
56
+ // wire and cut each stream to its own budget here, so the caller's
57
+ // per-stream contract holds and a stream under its cap is never flagged.
58
+ const wireCap = Math.max(caps.stdout, caps.stderr);
59
+ const reader = (slot) => {
60
+ const cap = slot === "stdout" ? caps.stdout : caps.stderr;
61
+ return {
62
+ readFrom(from) {
63
+ const buffer = state[slot] ?? Buffer.alloc(0);
64
+ const lossy = buffer.length > cap;
65
+ const visible = lossy ? buffer.subarray(0, cap) : buffer;
66
+ const start = Math.min(from, visible.length);
67
+ return { text: visible.subarray(start).toString("utf8"), lossy, nextOffset: visible.length };
68
+ },
69
+ };
70
+ };
71
+ const handle = {
72
+ get collected() {
73
+ if (state.delegate !== undefined)
74
+ return state.delegate.collected;
75
+ return { stdout: reader("stdout"), stderr: reader("stderr") };
76
+ },
77
+ done: (async () => {
78
+ let result;
79
+ try {
80
+ result = await agent.exec({ cwd, argv, env, stdin, maxOutputBytes: wireCap, signal: controller.signal });
81
+ state.stdout = discovery ? translateDiscoveryOutput(distro ?? "", result.stdout) : result.stdout;
82
+ state.stderr = result.stderr;
83
+ }
84
+ catch (error) {
85
+ if (!(error instanceof AgentUnavailableError)) {
86
+ if (controller.signal.aborted) {
87
+ // Our kill() or the caller's cancellation: the killed outcome the
88
+ // one-shot path reports, not a failure.
89
+ return { exitCode: null, signal: "SIGTERM" };
90
+ }
91
+ throw error;
92
+ }
93
+ const delegate = spawnFallback();
94
+ state.delegate = delegate;
95
+ return delegate.done;
96
+ }
97
+ return { exitCode: result.exitCode, signal: null };
98
+ })(),
99
+ kill() {
100
+ controller.abort(new Error("killed"));
101
+ state.delegate?.kill?.();
102
+ },
103
+ };
104
+ return handle;
105
+ }
@@ -13,19 +13,28 @@
13
13
  * ## What is routed, and what deliberately is not
14
14
  *
15
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.
16
+ * `\\wsl.localhost\\<distro>` UNC). Inside that, two cases:
17
+ *
18
+ * - **Snapshot calls** (`add`, `write-tree`, `ls-tree`, `cat-file`,
19
+ * `diff-tree`, `ls-files`, `check-ignore`) route whenever the program is
20
+ * git — the runs are fully isolated from the user's git state
21
+ * (`GIT_INDEX_FILE` / `GIT_OBJECT_DIRECTORY` /
22
+ * `GIT_ALTERNATE_OBJECT_DIRECTORIES` pointing at the tool's private
23
+ * scratch), so the distro git mutates scratch, never the repository.
24
+ * - **Discovery calls** (`--show-toplevel`, `--absolute-git-dir`, `--git-path`)
25
+ * print absolute paths that the caller resolves against the UNC workspace,
26
+ * so a distro answer in Linux paths would corrupt that resolution. They
27
+ * route only when the program is the SENTINEL path `DISTRO_GIT_SENTINEL` —
28
+ * the guard `resolveExecutable` returns when the HOST has no git — and the
29
+ * facade translates the output lines into UNC form before the caller sees
30
+ * them (`translateDiscoveryOutput`). A host-resolved git keeps the shipped
31
+ * host-side discovery, which answers UNC natively (and needs the documented
32
+ * `safe.directory` whitelist for WSL shares).
33
+ *
34
+ * The split means a machine without Windows git still gets full WSL change
35
+ * review: the sentinel resolves, every spawn routes, nothing touches the
36
+ * host. See `lib/git-exec.js` for the facade side.
37
+ *
29
38
  *
30
39
  * ## Environment translation
31
40
  *
@@ -44,6 +53,14 @@
44
53
  *
45
54
  * @module dsh-plugin-wsl/git-route
46
55
  */
56
+ /**
57
+ * The program path `resolveExecutable` returns for git when the HOST has no
58
+ * git: a Linux absolute path that only makes sense inside the distro, so any
59
+ * spawn carrying it must route (and a discovery spawn's output must come back
60
+ * translated). Chosen to match a standard distro install so the argv stays
61
+ * honest even where it is read verbatim.
62
+ */
63
+ export declare const DISTRO_GIT_SENTINEL = "/usr/bin/git";
47
64
  /**
48
65
  * Whether a spawn's program is git.
49
66
  *
@@ -97,4 +114,17 @@ export declare function gitSpawnRewrite({ argv, cwd, wslPath }: {
97
114
  linuxCwd: string;
98
115
  gitArgv: string[];
99
116
  wslArgv: string[];
117
+ discovery?: boolean;
100
118
  } | undefined;
119
+ /**
120
+ * Translate one routed discovery call's stdout into the caller's UNC
121
+ * coordinate system: every absolute Linux path line becomes
122
+ * `\\wsl.localhost\<distro><path>`; relative lines (`--git-path` answers
123
+ * `.git/objects` when the repository is not bare) pass through untouched,
124
+ * because the caller resolves them against the UNC cwd natively.
125
+ *
126
+ * @param distro - the distro the discovery ran in.
127
+ * @param stdout - the distro git's captured stdout.
128
+ * @returns the stdout with absolute path lines rewritten.
129
+ */
130
+ export declare function translateDiscoveryOutput(distro: string, stdout: Buffer): Buffer;
package/lib/git-route.js CHANGED
@@ -13,19 +13,28 @@
13
13
  * ## What is routed, and what deliberately is not
14
14
  *
15
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.
16
+ * `\\wsl.localhost\\<distro>` UNC). Inside that, two cases:
17
+ *
18
+ * - **Snapshot calls** (`add`, `write-tree`, `ls-tree`, `cat-file`,
19
+ * `diff-tree`, `ls-files`, `check-ignore`) route whenever the program is
20
+ * git — the runs are fully isolated from the user's git state
21
+ * (`GIT_INDEX_FILE` / `GIT_OBJECT_DIRECTORY` /
22
+ * `GIT_ALTERNATE_OBJECT_DIRECTORIES` pointing at the tool's private
23
+ * scratch), so the distro git mutates scratch, never the repository.
24
+ * - **Discovery calls** (`--show-toplevel`, `--absolute-git-dir`, `--git-path`)
25
+ * print absolute paths that the caller resolves against the UNC workspace,
26
+ * so a distro answer in Linux paths would corrupt that resolution. They
27
+ * route only when the program is the SENTINEL path `DISTRO_GIT_SENTINEL` —
28
+ * the guard `resolveExecutable` returns when the HOST has no git — and the
29
+ * facade translates the output lines into UNC form before the caller sees
30
+ * them (`translateDiscoveryOutput`). A host-resolved git keeps the shipped
31
+ * host-side discovery, which answers UNC natively (and needs the documented
32
+ * `safe.directory` whitelist for WSL shares).
33
+ *
34
+ * The split means a machine without Windows git still gets full WSL change
35
+ * review: the sentinel resolves, every spawn routes, nothing touches the
36
+ * host. See `lib/git-exec.js` for the facade side.
37
+ *
29
38
  *
30
39
  * ## Environment translation
31
40
  *
@@ -47,7 +56,15 @@
47
56
  import { isWslUnc, uncToPosix, windowsToLinuxMount } from "./paths.js";
48
57
  /** The one program this rewrite passes through to the distro. */
49
58
  const GIT_PROGRAM = "git";
50
- /** Discovery flags print absolute paths — host git keeps that contract. */
59
+ /**
60
+ * The program path `resolveExecutable` returns for git when the HOST has no
61
+ * git: a Linux absolute path that only makes sense inside the distro, so any
62
+ * spawn carrying it must route (and a discovery spawn's output must come back
63
+ * translated). Chosen to match a standard distro install so the argv stays
64
+ * honest even where it is read verbatim.
65
+ */
66
+ export const DISTRO_GIT_SENTINEL = "/usr/bin/git";
67
+ /** Discovery flags print absolute paths — the caller resolves them. */
51
68
  const DISCOVERY_FLAGS = ["--show-toplevel", "--absolute-git-dir", "--git-path"];
52
69
  /**
53
70
  * Whether a spawn's program is git.
@@ -125,7 +142,8 @@ export function translateGitEnv(env) {
125
142
  export function gitSpawnRewrite({ argv, cwd, wslPath }) {
126
143
  if (!Array.isArray(argv) || !isGitProgram(argv[0]))
127
144
  return undefined;
128
- if (namesDiscoveryPaths(argv))
145
+ const sentinel = argv[0] === DISTRO_GIT_SENTINEL;
146
+ if (namesDiscoveryPaths(argv) && !sentinel)
129
147
  return undefined;
130
148
  const named = uncToPosix(cwd);
131
149
  if (named === undefined)
@@ -137,5 +155,26 @@ export function gitSpawnRewrite({ argv, cwd, wslPath }) {
137
155
  linuxCwd,
138
156
  gitArgv,
139
157
  wslArgv: [wslPath, "-d", named.distro, "--cd", linuxCwd, "--exec", ...gitArgv],
158
+ discovery: sentinel && namesDiscoveryPaths(argv),
140
159
  };
141
160
  }
161
+ /**
162
+ * Translate one routed discovery call's stdout into the caller's UNC
163
+ * coordinate system: every absolute Linux path line becomes
164
+ * `\\wsl.localhost\<distro><path>`; relative lines (`--git-path` answers
165
+ * `.git/objects` when the repository is not bare) pass through untouched,
166
+ * because the caller resolves them against the UNC cwd natively.
167
+ *
168
+ * @param distro - the distro the discovery ran in.
169
+ * @param stdout - the distro git's captured stdout.
170
+ * @returns the stdout with absolute path lines rewritten.
171
+ */
172
+ export function translateDiscoveryOutput(distro, stdout) {
173
+ const text = stdout.toString("utf8");
174
+ if (!text.includes("\n") && !text.startsWith("/"))
175
+ return stdout;
176
+ const translated = text.split("\n").map((line) => line.startsWith("/")
177
+ ? `\\\\wsl.localhost\\${distro}${line.replaceAll("/", "\\")}`
178
+ : line);
179
+ return Buffer.from(translated.join("\n"), "utf8");
180
+ }