dsh-gitbash-shell 0.27.0 → 0.29.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,97 @@
3
3
  > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。
4
4
  > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。
5
5
 
6
+ ## v0.29.0 — 2026-09-26
7
+
8
+ **类型**:feat(用户实测需求 —— DSH 原生「新建终端」落到 **WSL**,插件只接管了 agent 的 shell 工具,没有接管官方侧栏终端)
9
+
10
+ > 用户原话:**"能不能让我的gitbash插件自动检测是不是没这个,然后帮忙补上一个 gitbash可以用,反正就自动的。"**
11
+ > 现象证据(用户自备):PATH 里 `bash` 只命中 `C:\WINDOWS\system32\bash.EXE`(Microsoft Bash Launcher)与
12
+ > `%LOCALAPPDATA%\Microsoft\WindowsApps\bash.EXE`,跑起来 `WSL_DISTRO_NAME=Ubuntu-20.04`;`C:\Program Files\Git\cmd`
13
+ > 虽在 PATH 但没有 `bash.exe`。本插件旧有的 `adoptSidebarShell` 写的是 **dsh-better-sidebar** 的命名空间,用户装的是
14
+ > dsh-better-workspace,那条通道被 skip。
15
+
16
+ ### 一、调研结论(全部实测,装置见下)
17
+ 1. **官方 settings 服务写不了 `terminal.shell`** —— 任务书设想的"正路"在当前 dsh 0.1.7-rc.2 上**不存在**:
18
+ `SettingsService.write()` 要求写入路径必须是 **volatile** 字段(`volatileForm(schema)` 为 `undefined` ⇒
19
+ `Plugin entry "…" has no volatile fields`),而 `TerminalController.Config` 的 `shell` 是普通字段;
20
+ 隔离实例探针实测 `settings.update('terminal', {shell})` 直接抛
21
+ `No configurable plugin entry "terminal"`(`describe()` 里也没有 `terminal` 这一行)。
22
+ 2. **可行且官方的通道是 `configEditor.edit()`** —— 官方设置 UI 持久化时用的同一个 API(注释:
23
+ "ordinary fields keep normal lifecycle rules")。探针写入后,**运行中的** `ctx.terminalController.config.shell`
24
+ 立刻读到新值 ⇒ **无需重启**;落盘到 profile 的 patch 层,由官方 YAML 编辑器**保留文件头注释**、按 `id` 合并该行。
25
+ 3. **`args: ['-i']` 有官方依据**:`shells.ts` 的 `profile()` 对非 cmd/pwsh 的 shell 一律给 `['-i']` —— 我们与官方一致,不是自创。
26
+ 4. **配置的 shell 恒排首位**(`discoverShells()` 先 `resolveShell(configured)` 再解析 candidates,按 path 去重),
27
+ 所以只需配 `shell`,**不动 `shellCandidates`**。另注意:菜单里仍可能出现一条候选 `bash`(在那些机器上解析到 WSL);
28
+ 默认项是我们写入的 Git Bash。
29
+ 5. **写错路径会直接把「新建终端」弄坏**:`resolveShell` 用 `subprocess.resolveExecutable()` 验证配置路径,
30
+ 失败即抛 `SubprocessExecutableNotFoundError`,**没有回退** ⇒ 只允许写 `src/bash-path.js` 已通过四项判据的 Git Bash。
31
+
32
+ ### 二、实现
33
+ - 新模块 `src/terminal-shell.js`:纯决策 `planTerminalAdopt()` + 执行器 `adoptTerminalShell()`,六态
34
+ `skip-platform` / `skip-disabled` / `skip-unresolved` / `skip-row-absent` / `unchanged` / `kept-user-choice` / `adopted` / `write-failed`。
35
+ - **只写"空"的**:该行没有自己的 shell ⇒ 写 `{path: <Git Bash>, name: 'bash', args: ['-i']}`;已是同一个(大小写/分隔符无关)⇒ 不写;
36
+ 已指向别处 ⇒ **绝不覆盖**,日志说明"这是你的显式选择"以及如何交还给 Git Bash。
37
+ - **写入后读回校验**(`configuration()` 的 `override`/`inherited`):`edit()` resolve 不等于值落进去了,不符 ⇒ `write-failed` 并 fail-loud
38
+ (延续 v0.28.0 的"绝不假成功")。
39
+ - 新开关 `autoTerminalShell`(volatile,**默认开**;旧宿主 settings 命名空间同名字段,缺键 = 开)+ 21 语言卡片文案
40
+ (`term.label`/`term.hint`);保留原 `dsh-better-sidebar` 通道(两条通道各自独立探测);不碰官方包、不手写用户 patch 文件。
41
+ - README 新增该节 + **用户三步复验清单**(开关与日志 → 终端里 `uname -r` 判 `MINGW64_NT-*` → 回退办法)。
42
+ **回退语义写明**:关掉开关**只阻止以后的自动写入**,**不会**删除已经写进配置的 `shell` 字段;要彻底恢复原样需删掉
43
+ profile patch 里 `terminal-controller` 行的 `shell` 字段并重启(21 语言卡片 hint 同步写上这一句)。
44
+ **菜单如实说明**:未改 `shellCandidates` ⇒ 选择菜单里**仍可能列出**解析到 WSL 的候选 `bash`,被改的是**默认项/首项**。
45
+
46
+ ### 三、验证
47
+ - `npm test` **99/99**(新增 5 条:空→写且形状为官方 `profile()` 约定 + 无关字段保留 / 同值→不写 + 异值→不写且日志含怎么交还 / 开关关·非 Windows·未解析·无该行→全只读 / 写入被拒或**未落盘**→ `write-failed` 不假成功 / 宿主侧只走 `configEditor` 且绝不调用 `settings.update('terminal')`);`align-official.mjs` 四变体仍逐字节对齐。
48
+ - **隔离实例真机(macOS,副本强制 win32 门 + 伪装已解析 Git Bash)**:
49
+ - 空值 ⇒ 日志 `switched to Git Bash: Q:/Git/bin/bash.exe (name bash, args -i) …`;
50
+ profile patch 里用户手写行与注释**原样保留**、新增 `- id: terminal-controller … shell {path,name,args}`;
51
+ `--dump-config` 读到 `terminal-controller.config.shell.path = Q:/Git/bin/bash.exe`;
52
+ - 第二次启动 ⇒ `already uses this Git Bash … (nothing written)`,patch **md5 未变**(幂等);
53
+ - 用户显式指向 `C:/Windows/System32/bash.exe` ⇒ `NOT adopted … an explicit choice is never overwritten`,patch **未变**;
54
+ - `autoTerminalShell: false` ⇒ `adoption is off …`,patch **未变**;
55
+ - 无头浏览器零 pageerror。
56
+ - **未在 Windows 验证**:真实 PATH/注册表命中、菜单里 shell 项、新终端里 `uname -r` 是否 `MINGW64_NT-*`;
57
+ 设置卡新增开关行的截图级渲染(该行复用既有开关写法,21 语言键由冒烟 parity 覆盖)。需用户按 README 三步复验。
58
+ - 相关:issue #11 后续(同一用户的 Windows 实测反馈)
59
+
60
+ ## v0.28.0 — 2026-09-26
61
+
62
+ **类型**:fix(issue #11 —— Git 没装在 `C:\Program Files\Git` 时,写死的默认 `bashPath` 让每条命令 `spawn … ENOENT`,而整个会话的命令能力归零且无提示)+ feat(fail-loud 引导:弹窗可填写路径 / 可跳转下载)
63
+
64
+ > 报障:https://github.com/KannaKuron/dsh-gitbash-shell/issues/11(Windows 11,Git 装在 `Q:\Git`;作者是另一台 DSH agent「蓝鲸」,诊断准确、自带 workaround)。两条用户口径逐字确立:**"默认用设置里填的 空的就去默认路径 没有就去用户环境path 和 系统path找 还没有就报错,弹窗让他去下载或者去设置里填写路径,而且要支持点击转跳过去"**;**"绝对不能因为找不到就用pwsh … 绝对不能用wsl"**。
65
+
66
+ ### 一、默认值假设被推翻:解析链接管
67
+ - **起因**:`cordis.patch.yml` 把 `gitbash-executor.config.bashPath` 写成 `C:/Program Files/Git/bin/bash.exe`;Git 装在别处 ⇒ 每条命令 ENOENT。更糟的是本插件撤掉 `pwsh-sandbox` 而 dsh 每进程只允许一个 `ctx.shell`,于是**连原本可用的 pwsh 也一并没了**,且没有任何提示。
68
+ - **新解析链(`src/bash-path.js`,唯一实现;执行器 `src/shell.js` 与翻译层 `src/index.js` 共用同一 memo)**:
69
+ **设置里填的**(`gitbash-executor` 行 config > `gitbash-shell` 行/设置卡;用 `ctx.loader.resolve('gitbash-executor')` 让翻译层看到同一个显式值,显式值失败也不换别的) → **默认安装位置**(Program Files / Program Files (x86) / ProgramW6432 / LOCALAPPDATA) → **PATH 逐目录 `bash.exe`** → **PATH 上 `git.exe` 反推** → **注册表 `Path`**(HKCU + HKLM,`reg query` 走 argv 数组) → **失败**。
70
+ `cordis.patch.yml` 的 `bashPath` 默认改为**空串(=自动)**。
71
+ - **只认 Git for Windows(用户第二条口径)**:黑名单先于探测(`system32`/`windowsapps`/`msys`/`cygwin`/`wsl`,归一化后逐段匹配 —— System32 是头号陷阱,它永远在 PATH 上);正向判据 = `<root>/cmd|bin/git.exe` + `<root>/usr/bin/bash.exe` + (`<root>/mingw64` 或 `usr/bin/msys-2.0.dll`);指纹 = `git --version` 含 `.windows.`;**一票否决** = 实跑候选 `bash -c "uname -s"` 必须是 `MINGW32_NT-*`/`MINGW64_NT-*`(`Linux`/`MSYS_NT-*`/`CYGWIN_NT-*` 一律不合格)。全部候选失败 ⇒ `{ ok:false, path:'' }`,**绝不返回替补 shell**。
72
+ - **永不回退(用户第一条口径,写进 AGENTS §4h)**:`pwsh-sandbox` 仍是无条件 `disabled: true`(与 bash 健康无关);`src/shell.js` 每次 argv 都过 `requireBashPath()`,解析失败**直接 throw 带引导的错误**(不再让用户只看到裸 ENOENT);冒烟守卫断言 patch 里没有 `!!js` 条件、执行器源码不出现 `pwsh.exe`/`powershell.exe`/`cmd.exe`/`wsl.exe`/裸 `bash`。
73
+
74
+ ### 二、fail-loud 与可点击引导
75
+ - **启动日志**:完整报告 —— 当前 `bashPath` 取到什么 / 按顺序探测过哪些位置(逐条 code+detail)/ 去哪里改 / 下载链接 / "本插件不会回退到 PowerShell"。
76
+ - **客户端弹窗**(`shell.overlay`,仅 win32 且 `ok:false`;同 boot 一次、`sessionStorage` 记关闭;21 语言新增 6 个 `bashmiss.*` 键):探测清单 + **内联 bashPath 编辑框(写 `configForms` 的同一字段,保存落盘到 profile 的 `cordis.patch.yml`)** + **「去下载 Git for Windows」**(`window.open`,Electron 下走外部浏览器)+ 关闭。
77
+ - **写入判定按宿主的布尔返回值(验收反馈修复,commit 见下)**:`ConfigForm.set()` 的契约是 `Promise<boolean>` = **Host 是否接受这次写入**。原先弹窗与设置卡的 bashPath 保存都**丢掉该返回值**、无条件显示「已保存」⇒ Host 拒绝时会**假成功**(与 §4h「绝不静默假成功」同一条纪律)。现在两处都按 `accepted === true` 决定成败,拒绝时显示 `bash.saveFailed`(21 语言,含"确认路径存在且是 Git for Windows 的 bash / 查看宿主日志"的下一步),并处理"没有 form 座位"这一同类情形(同样是拒绝而不是成功)。
78
+ - **数据通道**:host 半注册 `GET /dsh-gitbash-shell/api/status`(`ctx.inject(['webServer'])` 可选服务,家族既有范式同 dsh-ide-git),客户端**在 effect 里 fetch**(不在 apply 取服务,避开客户端半的取服务竞态);`gitBash` capability 同时**加法**扩展为 `{ active, bashPath, ok, source, configured, tried, downloadUrl }`。
79
+ - **深链实测结论**:官方设置页**没有**公开 API 能跳到指定插件的设置卡 —— `openSettings`/`openSection` 只发给 `settings.launcher`/`settings.onboarding` 占用者,面板开关状态是 `ui-settings-general` 私有的 store,`ctx.shortcuts` 也没有"按 id 执行命令"的入口。因此弹窗**自带编辑器**(用户不必去找设置页),文字指路作为兜底;上游若开放深链 API,这里可以直接换成按钮跳转。
80
+
81
+ ### 三、验证
82
+ - `npm test` **93/93 绿**(新增 7 条:解析链显式优先且**失败不替换** / 默认→PATH 含陷阱拒绝 / 只认 Git(WSL+MSYS2+Cygwin+`.windows.`+`uname` 矩阵)/ 全落空报告含引导与下载链接 / **无回退守卫** / 弹窗形状与 21 语言 / 路由与 capability 形状);`tests/align-official.mjs` 四变体仍逐字节对齐。
83
+ - **真机(隔离 `DSH_HOME` + 插件副本强制 win32 门 + 无头 Chrome/CDP,macOS)**:
84
+ - 启动日志打出完整报告(15 条探测记录,含 `missing`/`bad-shape` 分类);
85
+ - `curl /dsh-gitbash-shell/api/status` → `{"ok":false,"platform":"win32",…}`;
86
+ - **弹窗渲染**:标题「找不到 Git Bash —— 命令无法执行」+ 按钮 `[保存, 去下载 Git for Windows, 关闭]` + **12 条探测清单** + 输入框;
87
+ - **下载动作**:「去下载」点击后 `window.open` 收到 `https://git-scm.com/download/win`;
88
+ - **内联编辑**:填入 `Q:/Git/bin/bash.exe` → 保存 → 卡片显示「已保存」,**宿主侧落盘** `profiles/verif/cordis.patch.yml` 的 `bashPath: Q:/Git/bin/bash.exe`;
89
+ - **一次性**:关闭后刷新页面不再出现(会话内已记)。
90
+ - 过程中自查修掉一个真缺陷:一次性判定原先写在 render 里(设标志的那次渲染之后的**任何 re-render 都会让弹窗消失**),改为在 effect 中决定 + 本地 state 呈现。
91
+ - **拒绝态实测(隔离实例 + 副本把 host 写入强制为 `false` + 无头 Chrome/CDP)**:`showsSaved=false`、`showsFailure=true`,界面显示
92
+ 「写入失败: 保存未生效:宿主拒绝了这次写入 —— 确认路径存在且是 Git for Windows 的 bash,或查看宿主日志」,零 pageerror;
93
+ 弹窗此时仍有「去下载 Git for Windows」与探测清单作为下一步。`npm test` **94/94**(新增第 8 条守卫:两处写入都必须看布尔返回值、不得出现无条件 `setBashSaved(true)`、21 语言失败文案齐)。
94
+ - **未验证(如实标注,需 issue 作者在 Windows 真机复验)**:注册表 `Path` 兜底、真实 Git 安装布局、WSL/WindowsApps/MSYS2 实机路径的拒绝行为(均为 fake io 单测 + 静态核对);深链不可用是上游能力缺口。
95
+ - 相关:issue #11 https://github.com/KannaKuron/dsh-gitbash-shell/issues/11
96
+
6
97
  ## v0.27.0 — 2026-09-25
7
98
 
8
99
  **类型**:feat(新设置「子代理/队员是否也使用 Git Bash」,默认开启 —— 用户 2026-09-25 追加需求)
package/README.md CHANGED
@@ -126,6 +126,52 @@ dsh-ptc-cordis-preset 报告它的 `PTC 创造模式` 已经是 Git Bash 版。
126
126
  default: ptc-cordis # 或 standard-gitbash / minimal-gitbash
127
127
  ```
128
128
 
129
+ ## bash 从哪里来:解析链与失败引导(v0.28.0,issue #11)
130
+
131
+ `bashPath` **默认留空 = 自动探测**;解析顺序就是契约:
132
+
133
+ 1. **设置里填的**(显式值优先,失败也不会被别的路径顶替):
134
+ `gitbash-executor` 行 config 的 `bashPath` > `gitbash-shell` 行 / 设置卡的「Git Bash 路径」;
135
+ 2. **默认安装位置**:`C:/Program Files/Git/bin/bash.exe`、`%ProgramFiles(x86)%\Git`、`%ProgramW6432%\Git`、
136
+ `%LOCALAPPDATA%\Programs\Git\bin\bash.exe`;
137
+ 3. **PATH**(用户 PATH + 系统 PATH,进程内已合并)逐目录找 `bash.exe`;
138
+ 4. **PATH 上的 `git.exe` 反推** `<gitdir>/../bin/bash.exe`;
139
+ 5. **注册表 `Path`**(`HKCU\Environment`、`HKLM\...\Session Manager\Environment`)——覆盖"GUI 启动时 PATH 快照过期";
140
+ 6. **全落空 ⇒ 报错 + 引导**(启动日志一份完整报告,客户端弹一次引导框)。
141
+
142
+ > **只认 Git for Windows 的 bash。** WSL(`C:\Windows\System32\bash.exe`,永远在 PATH 且排序靠前)、
143
+ > WindowsApps 别名、MSYS2、Cygwin 的 bash **一律拒绝**;候选还要通过 Git 布局(`usr/bin` + `cmd\git.exe` +
144
+ > `mingw64`)、`git --version` 的 `.windows.` 指纹,以及**实跑 `bash -c "uname -s"` 必须是 `MINGW*_NT-*`**。
145
+ >
146
+ > **本插件不会回退到 PowerShell / cmd / 任何其它 shell。** 找不到 Git Bash 就是找不到:设计如此 ——
147
+ > 装了本插件就是要用 Git Bash,偷偷换一个能跑的顶上是错的。失败时弹窗给两件事:在弹窗里**直接填 bashPath**
148
+ > (写入设置卡同一字段)或**去下载 Git for Windows**;同一 boot 只弹一次,可关闭。
149
+
150
+ ## 官方侧栏终端(「新建终端」)自动切到 Git Bash(v0.29.0)
151
+
152
+ DSH 自己的「新建终端」**不经过**本插件的执行器:它由官方的 `terminal-controller` 决定 shell,未配置时用
153
+ 「执行环境的默认 shell」——而在 PATH 里 `bash` 指向 `C:\Windows\System32\bash.EXE`(WSL 启动器)的机器上,
154
+ 新终端跑起来就是 Ubuntu。本插件现在会自动把它切过来:
155
+
156
+ - 仅 **Windows**、且已解析出**通过验证**的 Git Bash 时;设置开关 **「自动接管侧栏终端」默认开启**(`autoTerminalShell`)。
157
+ - 只在官方那一行**没有自己的 shell** 时写入 `{ path: <Git Bash>, name: bash, args: ['-i'] }`
158
+ (`-i` 就是官方对 bash 的默认参数)。**你已经手动指定过别的 shell(例如某个 WSL 配置)⇒ 绝不覆盖**,只在宿主日志里提示,
159
+ 并告诉你如何交还给 Git Bash(清空该字段)。
160
+ - 写入走**官方配置编辑器**(设置 UI 用的同一个 API),落到 profile 的 patch 层;**新终端立即生效**,已打开的终端保持原样。
161
+ - **不需要重启**,也不会写坏「新建终端」:只写我们验证过存在的 Git Bash(官方对无法解析的路径没有回退,会直接启动失败)。
162
+ - 失败时 **fail-loud**:日志写清原因,并通过**读回校验**确认值真的落进去了(不出现"假成功")。
163
+ - ⚠️ **如实说明**:我们**没有**改 `shellCandidates`,所以「新建终端」的 shell 选择菜单里**仍可能列出**一条解析到 WSL 的
164
+ 候选 `bash` —— 被改掉的是**默认项/首项**(它现在是我们写入的 Git Bash),不是整条菜单。想彻底不让 WSL 出现在菜单里,
165
+ 需要另外调整 `terminal-controller` 行的 `shellCandidates`(本插件本轮**不做**,以免覆盖别人的部署配置)。
166
+
167
+ **三步复验(Windows)**:
168
+ 1. 装好 Git for Windows,重启 DSH,打开本插件设置卡:开关「自动接管侧栏终端」为开;宿主日志出现
169
+ `official sidebar terminal switched to Git Bash: …`。
170
+ 2. 新建终端,在终端里跑 `uname -r`:应看到 `MINGW64_NT-…`(WSL 会显示 Linux 内核版本如 `5.15…`)。
171
+ 3. **回退**:⚠️ 关掉设置卡里的开关**只阻止以后的自动写入,不会删除已经写进配置的字段**。要彻底恢复原样,
172
+ 请把 profile 的 patch 里 `terminal-controller` 行的 `shell` 字段**删掉**,再重启 DSH(终端随即回到原来的 shell);
173
+ 只想让它别再自动改、但不介意保留现状的话,关开关即可。
174
+
129
175
  ## 配置(执行器)
130
176
 
131
177
  `gitbash-executor` 行支持:
package/cordis.patch.yml CHANGED
@@ -32,7 +32,11 @@
32
32
  disabled: !!js process.platform !== 'win32'
33
33
  config:
34
34
  timeoutMs: 60000
35
- bashPath: "C:/Program Files/Git/bin/bash.exe"
35
+ # EMPTY = AUTO (v0.28.0, issue #11): the shared resolver walks
36
+ # setting > default install paths > PATH > registry PATH, and accepts
37
+ # ONLY Git for Windows' bash. A hard-coded C: path here made a Git
38
+ # installed elsewhere (e.g. Q:/Git) look like "dsh is broken".
39
+ bashPath: ""
36
40
 
37
41
  # Row id 'gitbash-shell' (was 'gitbash-presets' through v0.23.0): dsh >= 0.1.7
38
42
  # keys a plugin's live Config form and the one-shot legacy settings.yaml
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-gitbash-shell",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "main": "./src/index.js",
5
5
  "description": "Windows 全模式 Git Bash:用 Git for Windows bash 替换 PowerShell 执行器,并物化 standard/minimal/code/cordis 的 Git Bash 变体 preset。双 era 适配 dsh <= 0.1.1(内置 id 'code')与 >= 0.1.2(改名 'ptc'),启动探测自动选择,preset id 永不变。联动:dsh-ptc-cordis-preset(两者同装时 PTC 创造模式自动物化为 Git Bash 版)+ dsh-better-sidebar(终端 UI 标签与模型 terminal_* 工具自动切到 Git Bash)。",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gitbash-shell",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Windows 上让 agent 的每条 shell 命令改经 Git for Windows bash 执行;物化四个内置预设的 Git Bash 变体,统一 MSYS 盘根路径方言,侧栏终端接管可开关。· Run every agent shell command through Git for Windows bash on Windows; materialize Git Bash variants of the built-in presets, unify the MSYS drive-root path dialect, and adopt the sidebar terminal behind a switch.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,371 @@
1
+ /**
2
+ * dsh-gitbash-shell — Git for Windows bash RESOLUTION (issue #11).
3
+ *
4
+ * One resolver, used by BOTH the executor (src/shell.js) and the path-dialect
5
+ * translation layer (src/index.js). Before v0.28.0 the translation layer
6
+ * probed PATH on its own while the executor kept whatever `bashPath` the patch
7
+ * hard-coded, so a Git installed outside `C:/Program Files/Git` produced
8
+ * "translation works, the shell does not exist" — the split this module ends.
9
+ *
10
+ * THE TWO HARD RULES (user, 2026-09-25; see AGENTS.md §4h):
11
+ * 1. Never substitute another shell. No pwsh, no cmd, no WSL bash, no
12
+ * "temporarily keep something runnable": an unresolved bash is a FAILURE
13
+ * with guidance, never a silent downgrade.
14
+ * 2. Only Git for Windows' own bash counts. WSL's starter
15
+ * (`C:\Windows\System32\bash.exe`, always on PATH), the WindowsApps
16
+ * alias, MSYS2's and Cygwin's bash are REJECTED — they speak a different
17
+ * path world, so using one is worse than finding none.
18
+ *
19
+ * The resolver is deliberately injectable: every filesystem/process fact
20
+ * arrives through `io`, so the whole chain is unit-testable off-Windows.
21
+ */
22
+
23
+ import { execFileSync } from 'node:child_process'
24
+ import { existsSync } from 'node:fs'
25
+ import { dirname } from 'node:path'
26
+
27
+ /** The historical default — still the FIRST default-path candidate. */
28
+ export const DEFAULT_GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'
29
+
30
+ /** Where a user without Git for Windows should go. */
31
+ export const GIT_BASH_DOWNLOAD_URL = 'https://git-scm.com/download/win'
32
+
33
+ /**
34
+ * Path fragments (on the NORMALIZED path) that disqualify a bash outright.
35
+ * `system32` and `windowsapps` are WSL's two delivery points; `msys`/`cygwin`
36
+ * are the sibling Unix emulations; `wsl` catches distro-installed shims.
37
+ */
38
+ export const REJECTED_BASH_MARKERS = ['system32', 'windowsapps', 'msys', 'cygwin', 'wsl']
39
+
40
+ /** `git version 2.54.0.windows.1` — only Git for Windows prints this. */
41
+ export const GIT_WINDOWS_VERSION_MARKER = '.windows.'
42
+
43
+ /** The final veto: what `bash -c 'uname -s'` must print on Git for Windows. */
44
+ export const GIT_BASH_UNAME = /^MINGW(32|64)_NT-/
45
+
46
+ /** Exit codes/sources reported back to the caller. */
47
+ export const BASH_SOURCES = ['configured', 'default', 'path', 'path-git', 'registry']
48
+
49
+ /**
50
+ * Normalize a Windows path for comparison: forward slashes, lowercase, no
51
+ * duplicate separators, no trailing separator. Short (8.3) names cannot be
52
+ * expanded without the filesystem, so callers on Windows pass the resolved
53
+ * path when they have one.
54
+ * @param {unknown} value - candidate path.
55
+ * @returns {string} the normalized form ('' for non-strings).
56
+ */
57
+ export function normalizeBashPath(value) {
58
+ if (typeof value !== 'string') return ''
59
+ return value.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/+$/, '').toLowerCase()
60
+ }
61
+
62
+ /**
63
+ * Does this path point at something that is NOT Git for Windows' bash? The
64
+ * blacklist runs BEFORE any filesystem probe, so a WSL starter on PATH is
65
+ * never even considered a candidate (issue #11's head trap: System32 is
66
+ * always on PATH and sorts first).
67
+ * @param {unknown} candidate - candidate path.
68
+ * @returns {string|undefined} the matched marker, or undefined when clean.
69
+ */
70
+ export function rejectedBashMarker(candidate) {
71
+ const path = normalizeBashPath(candidate)
72
+ if (path === '') return undefined
73
+ const segments = path.split('/')
74
+ for (const marker of REJECTED_BASH_MARKERS) {
75
+ // Whole-segment matches only: "C:/tools/msys2/usr/bin/bash.exe" is caught by
76
+ // the `msys2` segment? No — by `msys` as a substring of that segment, which
77
+ // is exactly what we want (msys2, msys64, msys32). `system32`/`windowsapps`
78
+ // are full segments in practice; match the segment both ways.
79
+ for (const segment of segments) {
80
+ if (segment === marker || segment.includes(marker)) return marker
81
+ }
82
+ }
83
+ return undefined
84
+ }
85
+
86
+ /**
87
+ * The Git root a bash path implies (`<root>/bin/bash.exe` → `<root>`), or ''
88
+ * when the shape is wrong.
89
+ * @param {unknown} bashPath - candidate bash path.
90
+ * @returns {string} the normalized root, or ''.
91
+ */
92
+ export function gitRootOfBash(bashPath) {
93
+ const path = normalizeBashPath(bashPath)
94
+ const parts = path.split('/')
95
+ if (parts.length < 2 || parts[parts.length - 1] !== 'bash.exe') return ''
96
+ return parts.slice(0, -2).join('/')
97
+ }
98
+
99
+ /**
100
+ * Validate one candidate as Git for Windows' bash. Every step is a hard
101
+ * requirement; the first failure decides the rejection code.
102
+ * @param {string} candidate - absolute candidate path.
103
+ * @param {object} io - injected facts (see {@link defaultIo}).
104
+ * @returns {{ok: true, path: string, root: string, gitExe: string}
105
+ * | {ok: false, code: string, detail: string}} the verdict.
106
+ */
107
+ export function classifyBashCandidate(candidate, io) {
108
+ const marker = rejectedBashMarker(candidate)
109
+ if (marker !== undefined) {
110
+ return { ok: false, code: 'rejected-shell', detail: 'path matches the "' + marker + '" exclusion (WSL/MSYS2/Cygwin are not Git Bash)' }
111
+ }
112
+ const root = gitRootOfBash(candidate)
113
+ if (root === '') return { ok: false, code: 'bad-shape', detail: 'not a <git-root>/bin/bash.exe shape' }
114
+ if (!io.exists(candidate)) return { ok: false, code: 'missing', detail: 'file does not exist' }
115
+ // Git for Windows layout: a git binary beside it AND the MSYS runtime.
116
+ const gitExe = [root + '/cmd/git.exe', root + '/bin/git.exe'].find((path) => io.exists(path))
117
+ if (gitExe === undefined) return { ok: false, code: 'no-git-exe', detail: 'no cmd/git.exe or bin/git.exe under ' + root }
118
+ if (!io.exists(root + '/usr/bin/bash.exe')) return { ok: false, code: 'no-usr-bin', detail: 'no usr/bin/bash.exe under ' + root }
119
+ if (!(io.exists(root + '/mingw64') || io.exists(root + '/usr/bin/msys-2.0.dll'))) {
120
+ return { ok: false, code: 'no-mingw', detail: 'no mingw64/ and no usr/bin/msys-2.0.dll under ' + root }
121
+ }
122
+ // Version fingerprint: only Git for Windows prints `.windows.`.
123
+ const version = io.gitVersion(gitExe)
124
+ if (typeof version !== 'string' || !version.includes(GIT_WINDOWS_VERSION_MARKER)) {
125
+ return { ok: false, code: 'not-windows-git', detail: (version === undefined ? 'git --version failed' : 'git --version = "' + version + '"') + ' (no "' + GIT_WINDOWS_VERSION_MARKER + '" marker)' }
126
+ }
127
+ // The final veto: run the candidate itself.
128
+ const uname = io.uname(candidate)
129
+ if (typeof uname !== 'string' || !GIT_BASH_UNAME.test(uname.trim())) {
130
+ return { ok: false, code: 'not-git-bash', detail: "bash -c 'uname -s' = " + (uname === undefined ? 'failed' : JSON.stringify(uname)) + ' (not MINGW*_NT-*)' }
131
+ }
132
+ return { ok: true, path: candidate, root, gitExe }
133
+ }
134
+
135
+ /**
136
+ * The ordered candidate chain for one resolution, WITHOUT validation. Order is
137
+ * the contract (issue #11): explicit setting → default install locations →
138
+ * PATH → git.exe reverse lookup → registry PATH (the last one only helps when
139
+ * the process PATH snapshot predates a user's PATH edit).
140
+ * @param {object} input - resolution input.
141
+ * @param {string} input.configured - explicit path ('' = none).
142
+ * @param {object} io - injected facts.
143
+ * @returns {{path: string, source: string}[]} the ordered candidates.
144
+ */
145
+ export function bashCandidates({ configured }, io) {
146
+ const out = []
147
+ const explicit = typeof configured === 'string' ? configured.trim() : ''
148
+ if (explicit !== '') {
149
+ out.push({ path: explicit, source: 'configured' })
150
+ return out // an explicit setting is the user's answer; never substitute it
151
+ }
152
+ for (const path of io.defaults()) out.push({ path, source: 'default' })
153
+ for (const path of io.pathBashCandidates()) out.push({ path, source: 'path' })
154
+ for (const path of io.gitReverseCandidates()) out.push({ path, source: 'path-git' })
155
+ for (const path of io.registryBashCandidates()) out.push({ path, source: 'registry' })
156
+ return out
157
+ }
158
+
159
+ /**
160
+ * Resolve the Git for Windows bash to run. Pure w.r.t. every fact it needs —
161
+ * all of it arrives through `io`, which is why the whole chain (including the
162
+ * Windows-only registry step) is unit-testable anywhere.
163
+ * @param {object} [options] - resolution options.
164
+ * @param {string} [options.configured] - explicit `bashPath` from config.
165
+ * @param {object} [options.io] - injected facts; defaults to the real ones.
166
+ * @returns {{ok: boolean, path: string, root: string, gitExe: string, source: string,
167
+ * tried: {path: string, source: string, code: string, detail: string}[],
168
+ * configured: string}} the verdict, with the full probe list for guidance.
169
+ */
170
+ export function resolveGitBash(options = {}) {
171
+ const io = options.io ?? defaultIo()
172
+ const configured = typeof options.configured === 'string' ? options.configured.trim() : ''
173
+ const tried = []
174
+ const rejected = []
175
+ for (const candidate of bashCandidates({ configured }, io)) {
176
+ let verdict
177
+ try {
178
+ verdict = classifyBashCandidate(candidate.path, io)
179
+ } catch (error) {
180
+ verdict = { ok: false, code: 'probe-failed', detail: String(error && error.message ? error.message : error) }
181
+ }
182
+ if (verdict.ok) {
183
+ return { ok: true, path: verdict.path, root: verdict.root, gitExe: verdict.gitExe, source: candidate.source, tried, rejected, configured }
184
+ }
185
+ tried.push({ path: candidate.path, source: candidate.source, code: verdict.code, detail: verdict.detail })
186
+ rejected.push(verdict.code)
187
+ }
188
+ return { ok: false, path: '', root: '', gitExe: '', source: '', tried, rejected, configured }
189
+ }
190
+
191
+ /**
192
+ * The fail-loud report for one resolution — the text a user reads in the host
193
+ * log when Git Bash could not be resolved. It states the design rather than
194
+ * blaming the environment: this plugin will NOT fall back to another shell.
195
+ * @param {object} resolution - a {@link resolveGitBash} result.
196
+ * @returns {string} the multi-line report.
197
+ */
198
+ export function bashResolutionReport(resolution) {
199
+ const lines = []
200
+ if (resolution.ok) {
201
+ lines.push('Git Bash resolved from the ' + resolution.source + ' source: ' + resolution.path)
202
+ if (resolution.tried.length > 0) {
203
+ lines.push(' (probed first, rejected: ' + resolution.tried.map((row) => row.path + ' [' + row.code + ']').join(', ') + ')')
204
+ }
205
+ return lines.join('\n')
206
+ }
207
+ lines.push('Git Bash could NOT be resolved — the shell is unavailable by design, not broken: this plugin never falls back to PowerShell, cmd or a WSL/MSYS2/Cygwin bash.')
208
+ lines.push(resolution.configured === ''
209
+ ? ' bashPath is empty, so the automatic chain ran (setting > default install paths > PATH > registry PATH).'
210
+ : ' bashPath is set to "' + resolution.configured + '" and that value was rejected; nothing else is substituted.')
211
+ if (resolution.tried.length === 0) lines.push(' probed: (nothing — no candidate was produced)')
212
+ for (const row of resolution.tried) lines.push(' probed [' + row.source + '] ' + row.path + ' -> ' + row.code + ' (' + row.detail + ')')
213
+ lines.push(' fix: set the "Git Bash path" field in this plugin\'s settings, or install Git for Windows: ' + GIT_BASH_DOWNLOAD_URL)
214
+ lines.push(' note: only Git for Windows\' own bash is accepted; WSL (/Windows/System32/bash.exe), the WindowsApps alias, MSYS2 and Cygwin are refused on purpose.')
215
+ return lines.join('\n')
216
+ }
217
+
218
+
219
+ /**
220
+ * The explicit `bashPath` a SETTINGS row carries, read the era-aware way (the
221
+ * exact double shape src/index.js uses for better-sidebar's namespace): on
222
+ * dsh <= 0.1.6 through `settings.get(ns).bashPath`, on >= 0.1.7 through
223
+ * `settings.describe()`'s own row. Never throws; a missing service reads ''.
224
+ * @param {object} ctx - a context exposing `settings`.
225
+ * @param {string} [ns] - the settings namespace/row id (default this plugin's).
226
+ * @returns {string} the configured path, '' when unset or unreadable.
227
+ */
228
+ export function settingsBashPath(ctx, ns = 'gitbash-shell') {
229
+ try {
230
+ const settings = ctx && typeof ctx.get === 'function' ? ctx.get('settings') : undefined
231
+ if (!settings) return ''
232
+ if (typeof settings.get === 'function') {
233
+ const value = settings.get(ns)
234
+ return value && typeof value === 'object' && typeof value.bashPath === 'string' ? value.bashPath : ''
235
+ }
236
+ if (typeof settings.describe === 'function') {
237
+ const entry = settings.describe().find((row) => row !== null && typeof row === 'object' && row.ns === ns)
238
+ const value = entry === undefined ? undefined : entry.value
239
+ return value && typeof value === 'object' && typeof value.bashPath === 'string' ? value.bashPath : ''
240
+ }
241
+ return ''
242
+ } catch {
243
+ return ''
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Merge the explicit tiers of the chain: the row's OWN config wins over the
249
+ * settings row, which wins over empty (which means "run the automatic chain").
250
+ * The order is the contract (issue #11): an explicit answer is never
251
+ * substituted, and an empty one is not an answer at all.
252
+ * @param {unknown} ownRowValue - `gitbash-executor` row config's `bashPath`.
253
+ * @param {unknown} settingsValue - the `gitbash-shell` row/settings `bashPath`.
254
+ * @returns {string} the explicit path to resolve with.
255
+ */
256
+ export function effectiveConfiguredBashPath(ownRowValue, settingsValue) {
257
+ const own = typeof ownRowValue === 'string' ? ownRowValue.trim() : ''
258
+ if (own !== '') return own
259
+ return typeof settingsValue === 'string' ? settingsValue.trim() : ''
260
+ }
261
+
262
+
263
+ /**
264
+ * The `gitbash-executor` ROW's own `bashPath` — the highest-priority tier.
265
+ * Read through the Loader's public `resolve(id)` (the row id is part of this
266
+ * plugin's own bundle patch), so the translation layer sees the same explicit
267
+ * value the executor row carries instead of quietly resolving a different
268
+ * one. Absent row / absent config / any loader shape change reads ''.
269
+ * @param {object} ctx - a context exposing `loader`.
270
+ * @returns {string} the row's configured path, '' when unset.
271
+ */
272
+ export function executorConfiguredBashPath(ctx) {
273
+ try {
274
+ const entry = ctx && ctx.loader && typeof ctx.loader.resolve === 'function'
275
+ ? ctx.loader.resolve('gitbash-executor')
276
+ : undefined
277
+ const value = entry && entry.options ? entry.options.config : undefined
278
+ return value && typeof value.bashPath === 'string' ? value.bashPath : ''
279
+ } catch {
280
+ return ''
281
+ }
282
+ }
283
+
284
+ /**
285
+ * The real `io`: filesystem probes, the Windows registry PATH read (argv
286
+ * arrays, never a shell string) and the two subprocess fingerprints.
287
+ * Every call is defensive — a probe that throws is a candidate that fails,
288
+ * never a crash.
289
+ * @returns {object} the production io.
290
+ */
291
+ export function defaultIo() {
292
+ const run = (file, args) => {
293
+ try {
294
+ return String(execFileSync(file, args, { encoding: 'utf8', timeout: 5000, windowsHide: true })).trim()
295
+ } catch {
296
+ return undefined
297
+ }
298
+ }
299
+ const registryPath = (hive, key) => {
300
+ const out = run('reg', ['query', hive + '\\' + key, '/v', 'Path'])
301
+ if (typeof out !== 'string') return []
302
+ const match = /Path\s+REG_(?:EXPAND_)?SZ\s+(.*)/i.exec(out)
303
+ if (match === null) return []
304
+ return match[1].split(';').map((entry) => entry.trim()).filter((entry) => entry !== '')
305
+ }
306
+ const dirs = () => {
307
+ const out = []
308
+ for (const key of ['ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432']) {
309
+ const base = process.env[key]
310
+ if (typeof base === 'string' && base.trim() !== '') out.push(base.replace(/\\/g, '/') + '/Git/bin/bash.exe')
311
+ }
312
+ const local = process.env.LOCALAPPDATA
313
+ if (typeof local === 'string' && local.trim() !== '') out.push(local.replace(/\\/g, '/') + '/Programs/Git/bin/bash.exe')
314
+ return out
315
+ }
316
+ const pathDirs = () => String(process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').map((entry) => entry.trim()).filter((entry) => entry !== '')
317
+ return {
318
+ exists: (path) => {
319
+ try { return existsSync(path) } catch { return false }
320
+ },
321
+ defaults: () => [DEFAULT_GIT_BASH, ...dirs()],
322
+ pathBashCandidates: () => pathDirs().map((dir) => dir.replace(/\\/g, '/') + '/bash.exe'),
323
+ gitReverseCandidates: () => pathDirs()
324
+ .filter((dir) => /(^|[/\\])cmd$/i.test(dir) || /(^|[/\\])bin$/i.test(dir))
325
+ .flatMap((dir) => {
326
+ const parent = dirname(dir.replace(/\\/g, '/'))
327
+ return [parent + '/bin/bash.exe', dirname(parent) + '/bin/bash.exe']
328
+ }),
329
+ registryBashCandidates: () => [
330
+ ...registryPath('HKCU', 'Environment'),
331
+ ...registryPath('HKLM', 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment'),
332
+ ].flatMap((entry) => {
333
+ const dir = entry.replace(/%([^%]+)%/g, (whole, name) => {
334
+ const value = process.env[name] ?? process.env[name.toUpperCase()]
335
+ return typeof value === 'string' ? value : whole
336
+ })
337
+ return /bash\.exe$/i.test(dir) ? [dir] : [dir.replace(/\\/g, '/') + '/bash.exe']
338
+ }),
339
+ gitVersion: (gitExe) => run(gitExe, ['--version']),
340
+ uname: (bash) => run(bash, ['-c', 'uname -s']),
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Process-wide memo of the resolution. The translation layer and the executor
346
+ * MUST agree — a split ("translation resolved Q:/Git, the executor still looks
347
+ * at C:/...") is exactly issue #11 — so both read this one cache. A failed
348
+ * resolution is cached too: re-probing on every command would spawn `reg` and
349
+ * `bash` repeatedly, and the diagnosed failure is stable within a boot.
350
+ * @param {object} [options] - as {@link resolveGitBash}, plus `fresh` to bypass.
351
+ * @returns {object} the memoized resolution.
352
+ */
353
+ const memoized = new Map()
354
+
355
+ export function resolveGitBashCached(options = {}) {
356
+ const key = (typeof options.configured === 'string' ? options.configured.trim() : '')
357
+ + '\u0000' + (options.platform ?? process.platform)
358
+ if (options.fresh === true || !memoized.has(key)) {
359
+ memoized.set(key, resolveGitBash(options))
360
+ }
361
+ return memoized.get(key)
362
+ }
363
+
364
+ /**
365
+ * Drop the memo. Keyed by the configured value already, so a settings edit
366
+ * resolves afresh without this; the reset exists for tests and for a caller
367
+ * that knows the environment changed (e.g. a fresh registry read).
368
+ */
369
+ export function resetGitBashMemo() {
370
+ memoized.clear()
371
+ }