File size: 7,294 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
/**
 * Shell path bridge — translate between native win32 paths and the POSIX
 * path dialect spoken by the MSYS2 / Git Bash shell.
 *
 * The msys runtime gives the shell a POSIX path view native Node.js cannot
 * resolve (`/c/Users/x` is `C:\Users\x`; `/tmp/x` is `%TEMP%\x`, not
 * `<git-root>/tmp`). `toShellPath` renders native paths for bash command
 * lines; `fromShellPath` resolves model/shell-supplied paths for fs access,
 * translating drive-letter forms lexically and other root-relative paths
 * through `cygpath -w` next to the probed bash. Anything unconvertible
 * passes through unchanged, and both directions are identity outside win32
 * bash.
 *
 * Synchronous and self-contained (node builtins only):
 * `createShellPathBridge` takes injectable deps for tests;
 * `getShellPathBridge` bundles the Node defaults, memoised per env object.
 */

import { execFileSync as nodeExecFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import * as nodePath from 'node:path';

import type { Environment } from './environment';

export interface ShellPathBridge {
  /** Native win32 path → shell dialect, for building bash commands. Identity on posix. */
  toShellPath(nativePath: string): string;
  /** Model/shell-supplied path → native, for fs access. Identity when not convertible. */
  fromShellPath(path: string): string;
}

export type ShellPathBridgeEnv = Pick<Environment, 'osKind' | 'shellName' | 'shellPath'>;

export interface ShellPathBridgeDeps {
  readonly execFileSync: (file: string, args: readonly string[]) => string;
  readonly isFile: (path: string) => boolean;
}

const CYGPATH_TIMEOUT_MS = 5_000;

const DRIVE_COLON_RE = /^\/([a-zA-Z]):(?:[\\/]|$)/;
const CYGDRIVE_RE = /^\/cygdrive\/([a-zA-Z])(?:\/|$)/;
const DRIVE_RE = /^\/([a-zA-Z])(?:\/|$)/;

// cygpath semantics are undefined for the virtual filesystems.
const VIRTUAL_FS_PREFIXES: readonly string[] = ['/dev/', '/proc/', '/sys/'];

const WIN32_DRIVE_ABSOLUTE_RE = /^[A-Za-z]:[\\/]/;

function joinDrive(letter: string, rest: string): string {
  const normalizedRest = rest.replaceAll('\\', '/');
  return normalizedRest === ''
    ? `${letter.toUpperCase()}:/`
    : `${letter.toUpperCase()}:${normalizedRest}`;
}

/**
 * Lexical translation of shell-dialect drive paths (`/c/x`, `/c:/x`,
 * `/cygdrive/c/x`) to native win32 form — pure string rewriting, no cygpath
 * involved. Anything else is returned unchanged.
 */
export function translateShellDrivePath(path: string): string {
  const colonMatch = DRIVE_COLON_RE.exec(path);
  if (colonMatch !== null) {
    return joinDrive(colonMatch[1]!, path.slice(3));
  }
  const cygdriveMatch = CYGDRIVE_RE.exec(path);
  if (cygdriveMatch !== null) {
    return joinDrive(cygdriveMatch[1]!, path.slice(`/cygdrive/${cygdriveMatch[1]!}`.length));
  }
  const driveMatch = DRIVE_RE.exec(path);
  if (driveMatch !== null) {
    return joinDrive(driveMatch[1]!, path.slice(2));
  }
  return path;
}

export function createShellPathBridge(
  env: ShellPathBridgeEnv,
  deps: ShellPathBridgeDeps,
): ShellPathBridge {
  const enabled = env.osKind === 'Windows' && env.shellName === 'bash';

  // Lazily located on first use; `null` = not found → permanent pass-through.
  let cygpathExe: string | null | undefined;
  // Cache successes only: a missing cygpath.exe is a stable fact, but an
  // execution failure may be transient. First-segment caching is exact for
  // default (first-level) mount tables; deeper user mounts are out of scope.
  const segmentCache = new Map<string, string>();

  function locateCygpath(): string | null {
    if (cygpathExe !== undefined) return cygpathExe;
    const shellDir = nodePath.win32.dirname(env.shellPath);
    const candidates = [nodePath.win32.join(shellDir, 'cygpath.exe')];
    if (nodePath.win32.basename(shellDir).toLowerCase() === 'bin') {
      candidates.push(nodePath.win32.join(shellDir, '..', 'usr', 'bin', 'cygpath.exe'));
    }
    cygpathExe = candidates.find((candidate) => deps.isFile(candidate)) ?? null;
    return cygpathExe;
  }

  function resolveRootSegment(firstSegment: string): string | null {
    const cached = segmentCache.get(firstSegment);
    if (cached !== undefined) return cached;

    const exe = locateCygpath();
    if (exe === null) return null;
    let resolved: string;
    try {
      const output = deps.execFileSync(exe, ['-w', '-C', 'UTF8', '--', `/${firstSegment}`]);
      // cygpath appends a newline and may emit a trailing separator (`D:\`).
      const trimmed = output.replace(/\r?\n$/, '');
      if (!WIN32_DRIVE_ABSOLUTE_RE.test(trimmed) && !trimmed.startsWith('\\\\')) return null;
      resolved = trimmed.replace(/[\\/]$/, '');
    } catch {
      return null;
    }
    segmentCache.set(firstSegment, resolved);
    return resolved;
  }

  function fromShellPath(path: string): string {
    if (!enabled) return path;

    // Keep UNC out first: posix.normalize would collapse the leading `//`.
    if (path.startsWith('//')) return path;

    if (path.startsWith('/')) {
      // Fold dot segments first: `/tmp/..` is `/` in the shell VFS, not `%TEMP%\..`.
      const normalized = nodePath.posix.normalize(path);
      const lexical = translateShellDrivePath(normalized);
      if (lexical !== normalized) return lexical;
      if (normalized === '/') return normalized;
      if (VIRTUAL_FS_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return normalized;
      const firstSegment = normalized.slice(1).split('/')[0]!;
      const prefix = resolveRootSegment(firstSegment);
      if (prefix === null) return normalized;
      const remainder = normalized.slice(firstSegment.length + 1);
      const joined = `${prefix}${remainder}`.replaceAll('\\', '/');
      // A mounted drive root resolved from a bare segment (`D:`) stays absolute.
      return /^[A-Za-z]:$/.test(joined) ? `${joined}/` : joined;
    }

    return path;
  }

  function toShellPath(nativePath: string): string {
    if (!enabled) return nativePath;

    if (nativePath.startsWith('\\\\')) {
      return nativePath.replaceAll('\\', '/');
    }

    const driveMatch = /^([A-Za-z]):(?:[\\/]|$)/.exec(nativePath);
    if (driveMatch !== null) {
      const drive = driveMatch[1]!.toLowerCase();
      const rest = nativePath.slice(2).replaceAll('\\', '/');
      return `/${drive}${rest.startsWith('/') ? rest : `/${rest}`}`;
    }

    return nativePath.replaceAll('\\', '/');
  }

  return { toShellPath, fromShellPath };
}

const bridgeCache = new Map<string, ShellPathBridge>();

/**
 * Production convenience — Node's ambient `execFileSync` / `existsSync`,
 * memoised per shell identity so call sites that wrap the same probed
 * environment in a fresh object still share one bridge.
 */
export function getShellPathBridge(env: ShellPathBridgeEnv): ShellPathBridge {
  const key = `${env.osKind} ${env.shellName} ${env.shellPath}`;
  const cached = bridgeCache.get(key);
  if (cached !== undefined) return cached;
  const bridge = createShellPathBridge(env, {
    execFileSync: (file, args) =>
      nodeExecFileSync(file, [...args], {
        encoding: 'utf8',
        timeout: CYGPATH_TIMEOUT_MS,
        windowsHide: true,
      }),
    isFile: (path) => existsSync(path),
  });
  bridgeCache.set(key, bridge);
  return bridge;
}