@gavinc-cn/touchstone-dsh 0.2.0 → 0.3.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 (111) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +23 -15
  3. package/README.zh.md +17 -12
  4. package/auth.py +0 -0
  5. package/backend/lib/access.js +44 -0
  6. package/backend/lib/agents.js +341 -0
  7. package/backend/lib/args.js +94 -0
  8. package/backend/lib/auth.js +195 -0
  9. package/backend/lib/board.js +6154 -0
  10. package/backend/lib/builtin-assets.js +1177 -0
  11. package/backend/lib/casescan.js +456 -0
  12. package/backend/lib/chat.js +2053 -0
  13. package/backend/lib/cookie.js +76 -0
  14. package/backend/lib/db.js +2260 -0
  15. package/backend/lib/dshdriver.js +1148 -0
  16. package/backend/lib/dshevents.js +851 -0
  17. package/backend/lib/export-cases.js +261 -0
  18. package/backend/lib/feishu.js +1738 -0
  19. package/backend/lib/feishu_cfg.js +228 -0
  20. package/backend/lib/feishu_conv.js +1484 -0
  21. package/backend/lib/feishu_device.js +428 -0
  22. package/backend/lib/feishu_inbound.js +1674 -0
  23. package/backend/lib/feishu_provision.js +855 -0
  24. package/backend/lib/feishu_rest.js +398 -0
  25. package/backend/lib/feishu_slash.js +568 -0
  26. package/backend/lib/feishu_ws.js +950 -0
  27. package/backend/lib/http.js +312 -0
  28. package/backend/lib/lib.js +973 -0
  29. package/backend/lib/loadcase.js +749 -0
  30. package/backend/lib/loadgen.js +1407 -0
  31. package/backend/lib/localbus.js +164 -0
  32. package/backend/lib/lock.js +405 -0
  33. package/backend/lib/proc.js +278 -0
  34. package/backend/lib/prompts.js +1021 -0
  35. package/backend/lib/pybase64.js +114 -0
  36. package/backend/lib/pyjson.js +214 -0
  37. package/backend/lib/query.js +125 -0
  38. package/backend/lib/rag.js +1310 -0
  39. package/backend/lib/runner.js +4520 -0
  40. package/backend/lib/servercaps.js +182 -0
  41. package/backend/lib/serverstate.js +237 -0
  42. package/backend/lib/sessparse.js +1176 -0
  43. package/backend/lib/sse.js +207 -0
  44. package/backend/lib/static.js +84 -0
  45. package/backend/lib/taskjson.js +302 -0
  46. package/backend/lib/waitq.js +2114 -0
  47. package/backend/lib/worktree.js +588 -0
  48. package/backend/lib/zstd-frames.js +218 -0
  49. package/backend/routes/admin.js +553 -0
  50. package/backend/routes/agents.js +149 -0
  51. package/backend/routes/assets.js +145 -0
  52. package/backend/routes/board.js +1220 -0
  53. package/backend/routes/feishu.js +269 -0
  54. package/backend/routes/feishu_app.js +215 -0
  55. package/backend/routes/feishu_hook.js +358 -0
  56. package/backend/routes/index.js +297 -0
  57. package/backend/routes/projects.js +1059 -0
  58. package/backend/routes/sessions.js +946 -0
  59. package/backend/routes/tasks.js +1530 -0
  60. package/backend/schema/fixtures/ancient.sql +189 -0
  61. package/backend/schema/fixtures/pre_alter.sql +177 -0
  62. package/backend/schema/migrations.json +154 -0
  63. package/backend/schema/schema.json +1451 -0
  64. package/backend/schema/schema.sql +216 -0
  65. package/backend/server.js +498 -0
  66. package/board.py +30 -1
  67. package/builtin_assets.py +0 -0
  68. package/builtin_prompts/free_style/flow.md +0 -0
  69. package/builtin_prompts/free_style/load_test.md +0 -0
  70. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  71. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  72. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  73. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  74. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  75. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  76. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  77. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  78. package/db.py +294 -129
  79. package/dsh-plugin/cordis.patch.yml +0 -0
  80. package/dsh-plugin/install.sh +22 -8
  81. package/dsh-plugin/lib/client.bundle.js +0 -0
  82. package/dsh-plugin/lib/index.js +299 -29
  83. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  84. package/dsh-plugin/scripts/build.mjs +0 -0
  85. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  86. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  87. package/dsh-plugin/src/client.js +0 -0
  88. package/dshevents.py +0 -0
  89. package/export_cases.py +0 -0
  90. package/extensions/dsh-autocommit/asset.json +0 -0
  91. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  92. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  93. package/feishu_conv.py +0 -0
  94. package/lib.py +0 -0
  95. package/lifecycle.py +116 -13
  96. package/loadcase.py +0 -0
  97. package/loadgen.py +0 -0
  98. package/localbus.py +0 -0
  99. package/migrate_p7b.py +0 -0
  100. package/package.json +6 -1
  101. package/platcompat.py +0 -0
  102. package/prompts.py +0 -0
  103. package/rag.py +0 -0
  104. package/requirements.txt +0 -0
  105. package/touchstone.py +0 -0
  106. package/waitq.py +0 -0
  107. package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
  108. package/webui/dist-plugin/assets/{index-JLwyZZxC.js → index-UrMyeAXF.js} +110 -110
  109. package/webui/dist-plugin/index.html +2 -2
  110. package/worktree.py +0 -0
  111. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
@@ -0,0 +1,588 @@
1
+ /**
2
+ * 卡片独立 git worktree 支撑(`worktree.py` 393 行的 Node 对应物)。
3
+ *
4
+ * 设计出处:`doc_ai/plan/202610/20261006_0115_看板卡片独立worktree执行(开始按钮下拉+免排队).md`。
5
+ *
6
+ * ── 分批历史(两批,界限是「端点要不要写盘」) ────────────────────────────────────
7
+ * **M3(读口)**:唯一调用面是 `board.worktree_preview`(`board.py:1357`),它只用五个函数
8
+ * `branch_for`(51) / `preview`(117) / `repo_root`(100) / `worktree_path`(67) / `merge_status`(290),
9
+ * 加上只读依赖 `head_branch`(274)、`_porcelain`(359)、`_int`(351)、`_git`(84)。
10
+ * **M6-1(写口)**:`create`(206) / `status`(249) / `remove`(371) 及只服务它们的
11
+ * `_readable_worktree`(135) / `_relpath`(146) / `_write_text`(151) / `_normalize_pointers`(159)。
12
+ * 此前它们**临时**住在 `board.js`(M5-2 的 `wtCreate`/`wtStatus`/`wtRemove`),
13
+ * 现已整体迁入本文件,`board.js` 只留 `import { create as wtCreate, … }` 的薄别名。
14
+ *
15
+ * ── M6-1 顺手修掉的一个真缺陷(先红后绿的现场在 `backend/test/worktree-write.test.mjs`) ──
16
+ * 那次「临时安置」漏了 `worktree_path` 的 import:`board.js:1916` 直接引用裸标识符
17
+ * ⇒ worktree 直起(`enterDoingWorktree` → `wtCreate`)**必然** `ReferenceError`。
18
+ * M5 三层判据全绿也没抓到它,因为函数级/实例级用例都没走到该分支(M5 记录里的「未验项:
19
+ * 真 worktree create/remove」就是它)。教训:**未验项不是「待补的覆盖」,是「没被读过的代码」**。
20
+ *
21
+ * ── git 调用约定(照 plan §3.3,一个字节都不改) ──────────────────────────────────
22
+ * 一律 `git -C <目录> ...`;**不设 `GIT_DIR`/`GIT_WORK_TREE`**(本项目自身就是 9p 映射
23
+ * worktree 的特例,平台擅自改写会与用户仓库既有配置打架);git 非 0 退出一律把 stderr
24
+ * 截断成可读错误串上抛(返回值形态,不抛异常)。
25
+ *
26
+ * ── 与 Python 的形态差异(唯一必要的差异,语义等价) ─────────────────────────────
27
+ * ① **同步**:Python `subprocess.run` 是阻塞调用;Node 用 `spawnSync`(同样同步),
28
+ * 故本文件全部函数保持**同步**——`board.js` 的读路径因此也全是同步的,与 Python 同。
29
+ * **不**用 `execFileSync`:它把非 0 退出抛成异常,而本模块的契约是「rc !== 0 时把
30
+ * stderr 归一成错误串返回」。
31
+ * ② `proc.stdout`/`stderr` 缺省为 `''`(Python `capture_output=True` 同);`ENOENT`
32
+ * 等启动异常归一 `rc = -1` + `git 不可用: <原因>`(照 Python `except OSError`)。
33
+ * ③ 超时口径:Python `subprocess.run(timeout=60)` 超时抛 `TimeoutExpired`(被
34
+ * `except subprocess.SubprocessError` 捕获 ⇒ rc=-1);Node `spawnSync` 超时给
35
+ * `status=null` + `signal='SIGTERM'`,这里映射成同一个 rc=-1 分支。
36
+ * ④ 时间单位不变(60s / 300s),常量照抄。
37
+ *
38
+ * 零 npm 依赖:只用 `node:child_process` / `node:fs` / `node:path`。
39
+ */
40
+
41
+ import { spawnSync } from 'node:child_process';
42
+ import fs from 'node:fs';
43
+ import path from 'node:path';
44
+
45
+ /** git 命令超时(毫秒):查询类很快(`GIT_TIMEOUT` 60s) */
46
+ export const GIT_TIMEOUT_MS = 60_000;
47
+ /** 写类 git 命令超时(`GIT_WRITE_TIMEOUT` 300s)——本文件只读,保留常量供 M4 复用 */
48
+ export const GIT_WRITE_TIMEOUT_MS = 300_000;
49
+
50
+ /** 工作树落点目录名(平台自有目录下)与仓库内回退目录名 */
51
+ export const WT_DIRNAME = 'worktrees';
52
+ export const FALLBACK_DIRNAME = '.touchstone-worktrees';
53
+
54
+ /** 分支名前缀(plan D5) */
55
+ export const BRANCH_PREFIX = 'ts/card-';
56
+
57
+ /** 错误串截断长度(回给前端/日志用,避免把整段 git 输出贴出来) */
58
+ const ERR_MAX = 300;
59
+
60
+ /** Windows 盘符绝对路径(`D:/x`、`D:\x`):POSIX 的 `path.isAbsolute` 不认,需自行识别 */
61
+ const DRIVE_RE = /^[A-Za-z]:[\\/]/;
62
+
63
+ // --------------------------------------------------------------------------
64
+ // 路径推导(纯字符串,不落盘)
65
+ // --------------------------------------------------------------------------
66
+
67
+ /**
68
+ * 卡片对应的 worktree 分支名(确定性命名,plan D5)。
69
+ * @param {number|string} cardId
70
+ * @returns {string}
71
+ */
72
+ export function branchFor(cardId) {
73
+ return `${BRANCH_PREFIX}${Number.parseInt(cardId, 10)}`;
74
+ }
75
+
76
+ /**
77
+ * 路径归一化用于「是否在仓库内」比较(`worktree._norm`)。
78
+ *
79
+ * POSIX 下 `normcase` 是恒等,Windows 折大小写——Node 的 `path.normalize` 与
80
+ * `path.resolve` 在对应平台上的行为与 `os.path` 同类(两版都跑在同一平台上,
81
+ * 不比跨平台行为)。
82
+ * @param {string} p
83
+ * @returns {string}
84
+ */
85
+ function norm(p) {
86
+ return path.normalize(path.resolve(p));
87
+ }
88
+
89
+ /**
90
+ * child 是否位于 parent 目录内部(**含相等不算「内部」**,便于判 work_dir === project_dir)。
91
+ * @param {string} child
92
+ * @param {string} parent
93
+ * @returns {boolean}
94
+ */
95
+ function inside(child, parent) {
96
+ const c = norm(child);
97
+ const p = norm(parent);
98
+ return c !== p && c.startsWith(p.replace(/[\\/]+$/, '') + path.sep);
99
+ }
100
+
101
+ /**
102
+ * 推导卡片 worktree 落点(plan D4;纯字符串推导,不落盘)。
103
+ *
104
+ * `repoRoot` 非空时按「不得落在仓库内部」判定(git 顶层可能与 project_dir 不同,例如
105
+ * project_dir 只是仓库的一个子目录);空则退化为按 project_dir 判定。
106
+ *
107
+ * `name` 的取法照 Python:`os.path.basename(os.path.normpath(project_dir).rstrip("/\\")) or "project"`
108
+ * ——尾部斜杠先剥、`normpath` 收敛 `.`/`..`、basename 为 `''`(或 `.`/`/`)时回落 `project`。
109
+ *
110
+ * @param {object} project 项目行(读 project_dir / work_dir)
111
+ * @param {number|string} cardId
112
+ * @param {string} [repoRoot]
113
+ * @returns {string}
114
+ */
115
+ export function worktreePath(project, cardId, repoRoot = '') {
116
+ const projectDir = String(project.project_dir ?? '').trim();
117
+ const workDir = String(project.work_dir ?? '').trim() || projectDir;
118
+ const cleaned = path.normalize(projectDir).replace(/[\\/]+$/, '');
119
+ let name = path.basename(cleaned);
120
+ if (!name || name === '.' || name === path.sep) name = 'project';
121
+ const cid = Number.parseInt(cardId, 10);
122
+ let cand = path.join(workDir, WT_DIRNAME, `card_${cid}`);
123
+ const contain = repoRoot || projectDir;
124
+ if (contain && inside(cand, contain)) {
125
+ const parent = path.dirname(path.normalize(path.resolve(projectDir)));
126
+ cand = path.join(parent, FALLBACK_DIRNAME, `${name}_card_${cid}`);
127
+ }
128
+ return path.normalize(cand);
129
+ }
130
+
131
+ // --------------------------------------------------------------------------
132
+ // git 只读探测
133
+ // --------------------------------------------------------------------------
134
+
135
+ /**
136
+ * 跑一条 git 命令,返回 `{rc, out, err}`(err 已截断)。
137
+ *
138
+ * git 不可用(未安装/不可执行)/超时一律按 `rc = -1` 归一,错误串统一文案——调用方只需
139
+ * 判 `rc !== 0`。**不抛异常**(照 Python `_git`)。
140
+ *
141
+ * @param {string[]} args
142
+ * @param {string} cwd
143
+ * @param {number} [timeoutMs]
144
+ * @returns {{rc: number, out: string, err: string}}
145
+ */
146
+ export function git(args, cwd, timeoutMs = GIT_TIMEOUT_MS) {
147
+ const proc = spawnSync('git', ['-C', cwd, ...args], {
148
+ encoding: 'utf8',
149
+ timeout: timeoutMs,
150
+ windowsHide: true,
151
+ });
152
+ if (proc.error) {
153
+ return { rc: -1, out: '', err: `git 不可用: ${proc.error.message}` };
154
+ }
155
+ if (proc.status === null) {
156
+ // 被信号杀死/超时(Python 的 TimeoutExpired 同归一)
157
+ return { rc: -1, out: '', err: `git 不可用: 命令被中断或超时(${args.join(' ')})` };
158
+ }
159
+ const err = String(proc.stderr ?? '').trim();
160
+ return { rc: proc.status, out: String(proc.stdout ?? ''), err: err.slice(0, ERR_MAX) };
161
+ }
162
+
163
+ /**
164
+ * 项目目录所属 git 仓库顶层;非仓库 / git 不可用返回 `{root:null, error}`。
165
+ *
166
+ * 项目目录只是仓库子目录时返回真正的顶层——`git worktree add` 必须在顶层执行,否则工作树
167
+ * 会挂到子目录语义上(git 会拒绝)。
168
+ *
169
+ * @param {string} projectDir
170
+ * @returns {{root: string|null, error: string}}
171
+ */
172
+ export function repoRoot(projectDir) {
173
+ const d = String(projectDir ?? '').trim();
174
+ if (!d) return { root: null, error: '项目目录为空' };
175
+ if (!isDir(d)) return { root: null, error: `项目目录不存在: ${d}` };
176
+ const { rc, out, err } = git(['rev-parse', '--show-toplevel'], d);
177
+ if (rc !== 0 || !out.trim()) {
178
+ return { root: null, error: err || '项目目录不是 git 仓库' };
179
+ }
180
+ return { root: out.trim().split('\n')[0].trim(), error: '' };
181
+ }
182
+
183
+ /**
184
+ * 主仓库当前检出的分支名;游离 HEAD / 读取失败返回 `{name:'', error:中文原因}`。
185
+ *
186
+ * 平台的「主分支」口径就是它——worktree 由该分支检出,改动也回流到它。**不猜**
187
+ * `master`/`main`:仓库默认分支名由仓库自己决定,猜错会把改动合到错误的分支上。
188
+ *
189
+ * @param {string} root
190
+ * @returns {{name: string, error: string}}
191
+ */
192
+ export function headBranch(root) {
193
+ const { rc, out, err } = git(['rev-parse', '--abbrev-ref', 'HEAD'], root);
194
+ const name = String(out ?? '').trim();
195
+ if (rc !== 0) return { name: '', error: err || '无法读取主仓库分支' };
196
+ if (!name || name === 'HEAD') {
197
+ return { name: '', error: '主仓库处于游离 HEAD 状态,无法判定主分支' };
198
+ }
199
+ return { name, error: '' };
200
+ }
201
+
202
+ /** `fs.statSync` 的目录判据(不存在/权限不足一律 false,照 Python `os.path.isdir`) */
203
+ function isDir(p) {
204
+ try {
205
+ return fs.statSync(p).isDirectory();
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * 预览(**不落盘**):`{ok, error, path, branch, exists, root}`。
213
+ *
214
+ * `ok=false` 表示该项目不能建 worktree(非 git 仓库等),`error` 为中文原因;
215
+ * `exists=true` 表示目标路径已存在(前端可据此提示「将复用」)。
216
+ *
217
+ * @param {object} project
218
+ * @param {number|string} cardId
219
+ * @returns {{ok: boolean, error: string, path: string, branch: string,
220
+ * exists: boolean, root: string}}
221
+ */
222
+ export function preview(project, cardId) {
223
+ const projectDir = String(project.project_dir ?? '').trim();
224
+ const { root, error } = repoRoot(projectDir);
225
+ if (error) {
226
+ return {
227
+ ok: false,
228
+ error: `项目目录不是 git 仓库(${error})`,
229
+ path: '',
230
+ branch: branchFor(cardId),
231
+ exists: false,
232
+ root: '',
233
+ };
234
+ }
235
+ const p = worktreePath(project, cardId, root);
236
+ return {
237
+ ok: true,
238
+ error: '',
239
+ path: p,
240
+ branch: branchFor(cardId),
241
+ exists: isDir(p),
242
+ root,
243
+ };
244
+ }
245
+
246
+ /**
247
+ * 卡片 worktree 的合并就绪度(只读判定;「通过」时据此决定是否交给 agent 合并)。
248
+ *
249
+ * 返回对象(**不抛异常**,键名与 Python 逐字同):
250
+ * `{ok, error, path, branch, target, exists, branch_exists, dirty, dirty_count, ahead, behind}`
251
+ * - `target`:主仓库当前分支(见 `headBranch`),空串 = 判定失败;
252
+ * - `ahead`:`target..branch` 的提交数——**> 0 即有改动还没回流主分支**;
253
+ * - `behind`:`branch..target` 的提交数——主分支领先多少(agent 需先同步);
254
+ * - `dirty`/`dirty_count`:工作树未提交改动(含未跟踪;口径与 `status` 同);
255
+ * - `ok=false` 只在「工作树/主仓库读不到」时出现,调用方按「不拦用户」处置。
256
+ *
257
+ * 全程只跑 `rev-parse` / `rev-list --count` / `status --porcelain`——**不执行任何 git
258
+ * 合并命令**(用户 2026-10-07 约定:合并交 agent,平台只判定)。
259
+ *
260
+ * @param {object} project
261
+ * @param {object} card 卡片行(读 id / worktree)
262
+ * @returns {object}
263
+ */
264
+ export function mergeStatus(project, card) {
265
+ const out = {
266
+ ok: false,
267
+ error: '',
268
+ path: '',
269
+ branch: branchFor(card.id),
270
+ target: '',
271
+ exists: false,
272
+ branch_exists: false,
273
+ dirty: false,
274
+ dirty_count: 0,
275
+ ahead: 0,
276
+ behind: 0,
277
+ };
278
+ const p = String(card.worktree ?? '').trim();
279
+ out.path = p;
280
+ if (!p) {
281
+ out.error = '该卡片没有独立 worktree';
282
+ return out;
283
+ }
284
+ if (!isDir(p)) {
285
+ out.error = 'worktree 目录不存在';
286
+ return out;
287
+ }
288
+ out.exists = true;
289
+ const { root, error } = repoRoot(project.project_dir);
290
+ if (error) {
291
+ out.error = `项目目录不是 git 仓库(${error})`;
292
+ return out;
293
+ }
294
+ const head = headBranch(root);
295
+ if (head.error) {
296
+ out.error = head.error;
297
+ return out;
298
+ }
299
+ out.target = head.name;
300
+ const branch = out.branch;
301
+ const verify = git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], root);
302
+ out.branch_exists = verify.rc === 0;
303
+ if (!out.branch_exists) {
304
+ // 分支不存在(被删/从未创建):没有可合并的东西,按「无待合并」处置
305
+ out.ok = true;
306
+ return out;
307
+ }
308
+ const ahead = git(['rev-list', '--count', `${out.target}..${branch}`], root);
309
+ if (ahead.rc !== 0) {
310
+ out.error = `无法读取待合并提交数:${ahead.err}`;
311
+ return out;
312
+ }
313
+ out.ahead = gitInt(ahead.out);
314
+ const behind = git(['rev-list', '--count', `${branch}..${out.target}`], root);
315
+ if (behind.rc !== 0) {
316
+ out.error = `无法读取落后提交数:${behind.err}`;
317
+ return out;
318
+ }
319
+ out.behind = gitInt(behind.out);
320
+ const dirty = porcelain(p);
321
+ if (!dirty.ok) {
322
+ out.error = `无法读取工作树状态:${dirty.err}`;
323
+ return out;
324
+ }
325
+ out.dirty = dirty.lines.length > 0;
326
+ out.dirty_count = dirty.lines.length;
327
+ out.ok = true;
328
+ return out;
329
+ }
330
+
331
+ /**
332
+ * git 输出取整(异常输出归一 0,不抛)。
333
+ *
334
+ * 照 Python `_int`:取**第一行**转 int;空输出/非数字/无行都归 0。
335
+ * @param {string} text
336
+ * @returns {number}
337
+ */
338
+ export function gitInt(text) {
339
+ const line = String(text ?? '').trim().split('\n')[0];
340
+ const n = Number.parseInt(line, 10);
341
+ return Number.isNaN(n) ? 0 : n;
342
+ }
343
+
344
+ /**
345
+ * 工作树 `git status --porcelain` 的非空行(含未跟踪);读不到返回
346
+ * `{ok:false, lines:[], err:原因}`。
347
+ *
348
+ * dirty 判据与 `status` 同口径(有输出即脏)——两处读的是同一条命令,合并判定一次取到
349
+ * 「脏不脏」与「脏几处」。**Python 用 `str.splitlines()`**,它比 `str.split('\n')` 多认
350
+ * `\r`/`\u2028` 等行界,故这里用 `/\r\n|\r|\n|\u2028|\u2029/` 拆,再剔除空行。
351
+ *
352
+ * @param {string} p
353
+ * @returns {{ok: boolean, lines: string[], err: string}}
354
+ */
355
+ export function porcelain(p) {
356
+ const { rc, out, err } = git(['status', '--porcelain'], p);
357
+ if (rc !== 0) return { ok: false, lines: [], err };
358
+ return {
359
+ ok: true,
360
+ lines: String(out ?? '').split(/\r\n|\r|\n|\u2028|\u2029/).filter((ln) => ln.trim() !== ''),
361
+ err: '',
362
+ };
363
+ }
364
+
365
+ // ══════════════════════════════════════════════════════════════════════════
366
+ // 写口(`worktree.py:135-247 / 249-273 / 371-401`)
367
+ //
368
+ // M6-1 从 `board.js` 迁入(原 `wtCreate`/`wtStatus`/`wtRemove`,M5-2 临时安置)。
369
+ // 迁移中一并修掉「`worktreePath` 未导入」的 ReferenceError(见文件头)。
370
+ //
371
+ // 与 Python 的形态差异(本段新增一条,其余同文件头):
372
+ // ⑤ 读指针文件用 `readFileSync(..., 'utf8')`:Python 的 `open(..., encoding='utf-8')` 遇
373
+ // 非法字节抛 `UnicodeDecodeError`(**不是** `OSError`,会穿过 `except OSError` 上抛);
374
+ // Node 的解码器把坏字节替换成 U+FFFD、不抛。指针文件是 git 写的 ASCII 文本,两者实际同;
375
+ // 真要区分只有「有人手工把指针文件写成二进制」这一种场景。
376
+ // (`os.replace` ↔ `fs.renameSync`:libuv 在 Windows 上用 `MOVEFILE_REPLACE_EXISTING`,
377
+ // 同样是覆盖式替换,不构成差异。)
378
+ // ══════════════════════════════════════════════════════════════════════════
379
+
380
+ /** worktree `.git` 文件首行形态(`worktree.py:_GITFILE_RE`) */
381
+ const GITFILE_RE = /^\s*gitdir:\s*(.+?)\s*$/;
382
+
383
+ /**
384
+ * Python `(value or "").strip()` 的口径:`None`/`0`/`False`/`""`/缺键一律归空串。
385
+ *
386
+ * 不写 `String(value ?? '')`:那会把 `0` 变成 `"0"`(M5 在 `normalizeEffort` 上踩过同一个坑)。
387
+ * 库里这几列都是 TEXT,两种写法对真实行同值,但**口径要照 oracle**。
388
+ * @param {unknown} value
389
+ * @returns {string}
390
+ */
391
+ function orStrip(value) {
392
+ return value ? String(value).trim() : '';
393
+ }
394
+
395
+ /** 异常 → 文本(`str(e)` 口径:`Error` 取 message,不带类名前缀) */
396
+ function errText(err) {
397
+ if (err === null || err === undefined) return '';
398
+ if (err instanceof Error) return String(err.message ?? err);
399
+ return String(err);
400
+ }
401
+
402
+ /** `worktree._readable_worktree`:path 是否已是「可用的、检出指定分支」的工作树(复用判据) */
403
+ function readableWorktree(p, branch) {
404
+ if (!isDir(p)) return false;
405
+ const inside = git(['rev-parse', '--is-inside-work-tree'], p);
406
+ if (inside.rc !== 0 || inside.out.trim() !== 'true') return false;
407
+ const head = git(['rev-parse', '--abbrev-ref', 'HEAD'], p);
408
+ return head.rc === 0 && head.out.trim() === branch;
409
+ }
410
+
411
+ /** `worktree._relpath`:相对路径,统一 `/` 分隔(git 指针文件两端平台都认正斜杠) */
412
+ function relpath(target, start) {
413
+ return path.relative(start, target).split(path.sep).join('/');
414
+ }
415
+
416
+ /** `worktree._write_text`:原子写文本(同目录临时文件 + rename,避免中断留半个文件) */
417
+ function writeTextAtomic(file, text) {
418
+ const tmp = `${file}.tmp_tswt`;
419
+ fs.writeFileSync(tmp, text, { encoding: 'utf8' });
420
+ fs.renameSync(tmp, file);
421
+ }
422
+
423
+ /**
424
+ * `worktree._normalize_pointers`:把工作树两处指针改写为**相对路径**(跨平台可用)。
425
+ *
426
+ * 改写对象:① `<worktree>/.git`(内容 `gitdir: <repo>/.git/worktrees/<name>`);
427
+ * ② `<repo>/.git/worktrees/<name>/gitdir`(内容 `<worktree 绝对路径>`)。
428
+ * 步骤:读原文 → 算相对形式 → 写入 → **用 git 复验**(`rev-parse --show-toplevel` 能解析)
429
+ * → 失败把两处原文写回。任何读取失败 / 已被改成相对路径都直接返回(不动)——best-effort,
430
+ * 绝不因「指针美化」把可用的工作树改坏。
431
+ *
432
+ * 注意 `adminAbs = path.normalize(admin)` 照 Python `os.path.normpath(admin)`:**不以工作树
433
+ * 目录为基准解析**(能走到这里的 `admin` 必是绝对路径或盘符形态,见上面的早退)。
434
+ * @param {string} p 工作树目录
435
+ */
436
+ function normalizePointers(p) {
437
+ const gitfile = path.join(p, '.git');
438
+ let gitfileText;
439
+ try {
440
+ gitfileText = fs.readFileSync(gitfile, 'utf8');
441
+ } catch {
442
+ return;
443
+ }
444
+ const lines = String(gitfileText ?? '').split(/\r\n|\r|\n|\u2028|\u2029/);
445
+ const m = GITFILE_RE.exec(gitfileText ? lines[0] : '');
446
+ if (!m) return;
447
+ const admin = m[1];
448
+ // 已是相对路径(含 Windows 盘符形态的精确识别)则不动
449
+ if (!(path.isAbsolute(admin) || DRIVE_RE.test(admin))) return;
450
+ const adminAbs = path.normalize(admin);
451
+ const gdFile = path.join(adminAbs, 'gitdir');
452
+ let gdText;
453
+ try {
454
+ gdText = fs.readFileSync(gdFile, 'utf8');
455
+ } catch {
456
+ return;
457
+ }
458
+ const newGitfile = `gitdir: ${relpath(adminAbs, p)}\n`;
459
+ const newGd = `${relpath(path.normalize(p), adminAbs)}\n`;
460
+ try {
461
+ writeTextAtomic(gitfile, newGitfile);
462
+ writeTextAtomic(gdFile, newGd);
463
+ } catch {
464
+ return;
465
+ }
466
+ if (git(['rev-parse', '--show-toplevel'], p).rc === 0) return;
467
+ // 复验失败:回滚(宁可留绝对指针也不能把可用工作树改坏)
468
+ try {
469
+ writeTextAtomic(gitfile, gitfileText);
470
+ writeTextAtomic(gdFile, gdText);
471
+ } catch {
472
+ /* 回滚也失败:留痕由调用方日志承担(Python 同:`except OSError: pass`) */
473
+ }
474
+ }
475
+
476
+ /**
477
+ * `worktree.create`:创建(或复用)卡片独立 worktree;返回 `[path, branch, err]`。
478
+ *
479
+ * 幂等语义(plan §3.5):① 路径已是可用工作树且检出目标分支 ⇒ 直接复用(打回续改/失败重按
480
+ * 开始走这条);② 路径不存在(用户手删)⇒ 重新创建:分支已存在则检出该分支(保留既往提交),
481
+ * 否则 `-b` 新建;③ 报错一律返回中文错误串(调用方按 400 回给前端)。
482
+ *
483
+ * git add 失败先用 `worktree prune` 清注册残留再重试**一次**(用户删过目录的常见形态)。
484
+ * **不抛异常**(错误走第三元)。
485
+ *
486
+ * @param {object} project 项目行(读 project_dir / work_dir)
487
+ * @param {number|string} cardId
488
+ * @returns {[string, string, string]}
489
+ */
490
+ export function create(project, cardId) {
491
+ const projectDir = orStrip(project.project_dir);
492
+ const { root, error } = repoRoot(projectDir);
493
+ if (error) return ['', '', `无法创建 worktree:项目目录不是 git 仓库(${error})`];
494
+ const p = worktreePath(project, cardId, root);
495
+ const branch = branchFor(cardId);
496
+ if (readableWorktree(p, branch)) return [p, branch, ''];
497
+ if (fs.existsSync(p)) {
498
+ // 路径被占(文件/非本卡的工作树):不擅自删用户目录,明确报错交用户处理
499
+ return ['', '', `worktree 路径已存在且不可复用:${p}`];
500
+ }
501
+ try {
502
+ fs.mkdirSync(path.dirname(p), { recursive: true });
503
+ } catch (e) {
504
+ return ['', '', `无法创建 worktree 目录:${errText(e)}`];
505
+ }
506
+ const verify = git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], root);
507
+ const args = verify.rc === 0
508
+ // 分支已存在(用户删过工作树目录、分支留着):检出既有分支,保留既往提交
509
+ ? ['worktree', 'add', p, branch]
510
+ : ['worktree', 'add', p, '-b', branch];
511
+ let res = git(args, root, GIT_WRITE_TIMEOUT_MS);
512
+ if (res.rc !== 0) {
513
+ // 目标目录不存在但 git 侧仍有注册残留(用户删过目录):prune 后重试一次
514
+ if (git(['worktree', 'prune'], root).rc === 0) {
515
+ res = git(args, root, GIT_WRITE_TIMEOUT_MS);
516
+ }
517
+ }
518
+ if (res.rc !== 0) {
519
+ const why = String(res.err || res.out || '未知原因').trim().slice(0, ERR_MAX);
520
+ return ['', '', `git worktree add 失败:${why}`];
521
+ }
522
+ normalizePointers(p);
523
+ return [p, branch, ''];
524
+ }
525
+
526
+ /**
527
+ * `worktree.status`:卡片工作树的改动状态 `{ok, error, path, branch, dirty, exists}`。
528
+ *
529
+ * dirty 判据 = `git status --porcelain` **输出非空白**(含未跟踪文件)——与 plan D8 的
530
+ * 「只在干净时清理」同口径。注意这是「整段输出判空」,与 `merge_status` 的「行数」不同口径,
531
+ * 两者**不要统一**(照 oracle)。
532
+ *
533
+ * @param {object} project 项目行(本函数暂未用到,形参照 Python 保留)
534
+ * @param {object} card 卡片行(读 id / worktree)
535
+ * @returns {object}
536
+ */
537
+ export function status(project, card) {
538
+ const p = orStrip(card.worktree);
539
+ const branch = branchFor(card.id);
540
+ if (!p) {
541
+ return {
542
+ ok: false, error: '该卡片没有独立 worktree', path: '', branch, dirty: false, exists: false,
543
+ };
544
+ }
545
+ if (!isDir(p)) {
546
+ return {
547
+ ok: false, error: 'worktree 目录不存在', path: p, branch, dirty: false, exists: false,
548
+ };
549
+ }
550
+ const res = git(['status', '--porcelain'], p);
551
+ if (res.rc !== 0) {
552
+ return {
553
+ ok: false, error: `无法读取工作树状态:${res.err}`, path: p, branch,
554
+ dirty: false, exists: true,
555
+ };
556
+ }
557
+ return {
558
+ ok: true, error: '', path: p, branch, dirty: Boolean(res.out.trim()), exists: true,
559
+ };
560
+ }
561
+
562
+ /**
563
+ * `worktree.remove`:清理卡片 worktree(plan D8:**只在干净时**、绝不用 `--force`);
564
+ * 返回 `[ok, err]`。
565
+ *
566
+ * 只 `git worktree remove`;**不删分支**(分支里的提交可能还没回流,删了要 reflog 才找得回;
567
+ * 清理分支交用户在 git 侧自行决定)。
568
+ *
569
+ * @param {object} project 项目行(只用于 `repo_root`)
570
+ * @param {object} card 卡片行(读 id / worktree)
571
+ * @returns {[boolean, string]}
572
+ */
573
+ export function remove(project, card) {
574
+ const p = orStrip(card.worktree);
575
+ if (!p) return [false, '该卡片没有独立 worktree'];
576
+ if (!isDir(p)) return [false, 'worktree 目录不存在'];
577
+ const st = status(project, card);
578
+ if (!st.ok) return [false, st.error];
579
+ if (st.dirty) return [false, '工作树有未提交改动,请先提交或清理后再移除'];
580
+ const { root, error } = repoRoot(project.project_dir);
581
+ if (error) return [false, `项目目录不是 git 仓库(${error})`];
582
+ const res = git(['worktree', 'remove', p], root, GIT_WRITE_TIMEOUT_MS);
583
+ if (res.rc !== 0) {
584
+ const why = String(res.err || res.out || '未知原因').trim().slice(0, ERR_MAX);
585
+ return [false, `git worktree remove 失败:${why}`];
586
+ }
587
+ return [true, ''];
588
+ }