agent-syncer 0.1.0 → 0.1.1

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/lib/record.js ADDED
@@ -0,0 +1,380 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { projectItemPath } from './install.js';
5
+ import { SUPPORT_DIR, parseItem } from './manifest.js';
6
+ import { CONTENT_ROOT } from './target.js';
7
+
8
+ /**
9
+ * 「装过什么」的记录文件。
10
+ *
11
+ * ## 为什么必须有它
12
+ *
13
+ * 光靠文件系统推不出来。看起来有条捷径:「`.agents/` 里有、内容仓库里没有 ⇒
14
+ * 是用户自己建的,别动」。但它恰恰在最要紧的情况下失效——**内容仓库某次
15
+ * 把某个技能整个删掉了**,那份内容真的源里已经没有它了,这个启发式会把它
16
+ * 误判成用户内容而永久保留。sync 只会说一句「不在本模板里——不会自动删除」,
17
+ * 用户既不知道它是谁装的,也没有任何办法把它清掉。
18
+ *
19
+ * 更要命的是 `scripts/`:`findOrphans` 明确跳过了它,所以内容仓库删掉一个脚本,
20
+ * 项目里那份会**一声不吭**地留着。
21
+ *
22
+ * 所以需要一个记录,回答「这些内容是本工具放进去的吗」。
23
+ *
24
+ * ## 只记名字,不记内容指纹
25
+ *
26
+ * 一开始这里存的是每条内容的哈希,用来判断「用户改过没有」。去掉了,两个原因:
27
+ *
28
+ * 1. **换行符**。`core.autocrlf` 的 Windows 检出会把同一份内容算成不同哈希,
29
+ * 于是记录会在 LF / CRLF 两种值之间来回翻,而每个项目都提交一份这个文件。
30
+ * 误判的方向倒是保守(该删的变成不删),但翻来覆去本身就是冲突源。
31
+ * 2. **它让记录频繁变动**。内容一更新哈希就变,等于每次内容改动都在每个项目的
32
+ * 仓库里多出一份 diff,而那份改动 `.agents/` 里本来就看得见。
33
+ *
34
+ * 代价是「用户改过的残留」不再被自动认出来。补法是在 `agents.json` 里**显式声明**
35
+ * `protect`——把「猜」换成「说」,也是这个项目一贯的口径:拿不准就不动,拿不准
36
+ * 就让用户讲。
37
+ *
38
+ * ## 只记名字,也不记内容仓库地址
39
+ *
40
+ * 这个文件是**要提交进版本库**的(它描述的是 `.agents/` 的内容,与机器无关;
41
+ * 不提交的话队友克隆后清理功能等于失效)。因此里面绝不能出现内部 git 地址——
42
+ * 部门规则第 3 条,也避免每个项目仓库都带上一份内部坐标。
43
+ *
44
+ * ## 还有一个 `merged`:合并产物的归属
45
+ *
46
+ * skills / rules 那几类落在 `.agents/` 里,`installed` 说「这个文件是本工具放的」
47
+ * 就够了。但 hooks / mcp 要**合并进各工具自己的配置文件**(`.mcp.json`、
48
+ * `.claude/settings.json`),那些文件里还有用户自己的东西——于是问题变成了
49
+ * 「配置文件里的**那一条**是不是我们写的」,而 `installed` 回答不了:
50
+ *
51
+ * - 用户 `.mcp.json` 里本来就有一个同名 server,我们报冲突没敢写,但
52
+ * `installed` 照样会记上 `mcp:foo`(`.agents/mcp/foo.json` 确实是装了的),
53
+ * 于是**下一轮就把它当成自己人覆盖掉**——静默丢掉用户的东西
54
+ * - 用户手删了 `.agents/mcp/foo.json`,`planRemovals` 因为存在性检查不成立
55
+ * 而不认它,配置文件里那一条就**永久残留**
56
+ *
57
+ * 所以另记一份 `merged`:**我们到底往哪个文件里写了什么**。判定因此升级成
58
+ * 「记录说是我写的,**且现场确实还是我写的那份**」才动——现场对不上就判为
59
+ * 用户编辑过,保留并警告。
60
+ *
61
+ * 存的是**解析后的结构**,不是原文、也不是哈希。所以上面「去掉哈希」那两条理由
62
+ * 在这里不成立:`core.autocrlf` 影响的是字节,解析出来的结构是一样的。
63
+ * 比较一律走**无序**深度相等——工具重排键序不该被当成「内容变了」。
64
+ */
65
+ export const RECORD_REL = `${CONTENT_ROOT}/.agent-sync.json`;
66
+
67
+ /**
68
+ * 记录文件的格式版本,与内容仓库的 schemaVersion 无关。
69
+ * 2 = 去掉内容指纹,`installed` / `scripts` 改成字符串数组。
70
+ */
71
+ export const RECORD_SCHEMA = 2;
72
+
73
+ /** @param {string} projectRoot */
74
+ export function recordPath(projectRoot) {
75
+ return path.resolve(projectRoot, RECORD_REL);
76
+ }
77
+
78
+ /** @param {string} projectRoot @param {string} rel */
79
+ function scriptPath(projectRoot, rel) {
80
+ return path.resolve(projectRoot, CONTENT_ROOT, SUPPORT_DIR, rel);
81
+ }
82
+
83
+ /** 只留字符串、去重、排序——手改坏了的记录不该让整件事崩掉 */
84
+ function strList(value) {
85
+ if (!Array.isArray(value)) return [];
86
+ /** @type {string[]} */
87
+ const out = [];
88
+ for (const v of value) if (typeof v === 'string' && !out.includes(v)) out.push(v);
89
+ return out.sort();
90
+ }
91
+
92
+ /** JSON 意义上的「一个对象」——null 和数组都不算 */
93
+ function isPlainObject(value) {
94
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
95
+ }
96
+
97
+ /**
98
+ * 换行符归一,**只为比较用**。写出去的一律是 LF。
99
+ *
100
+ * `core.autocrlf=true` 的检出会让盘上的记录变成 CRLF,而我们是按 LF 拼的。
101
+ * 按字节比的话,每次重新检出之后第一次 sync 都会重写这个文件,git 里永远
102
+ * 挂着一份 modified——正是上面「去掉哈希」那段要躲的病,换了个渠道又回来了。
103
+ * 归一之后只比不管:盘上是什么换行就让它留着。
104
+ *
105
+ * @param {string|null} s
106
+ */
107
+ function eol(s) {
108
+ return s === null ? null : s.replace(/\r\n/g, '\n');
109
+ }
110
+
111
+ /**
112
+ * 归一化 `merged`。
113
+ *
114
+ * 结构:`{ <工具>: { mcp: {<id>: server对象}, hooks: {<id>: {<事件名>: [条目]}} } }`
115
+ *
116
+ * 手改坏的部分一律丢掉——一份被改坏的记录不该把 sync 弄崩。丢掉的方向是
117
+ * 保守的:不认的条目就等于「不是本工具写的」,于是不碰。
118
+ *
119
+ * @param {any} value
120
+ * @returns {Record<string, any>}
121
+ */
122
+ function mergedMap(value) {
123
+ if (!isPlainObject(value)) return {};
124
+
125
+ /** @type {Record<string, any>} */
126
+ const out = {};
127
+ for (const [tool, kinds] of Object.entries(value)) {
128
+ if (!isPlainObject(kinds)) continue;
129
+ /** @type {Record<string, any>} */
130
+ const kept = {};
131
+
132
+ if (isPlainObject(kinds.mcp)) {
133
+ /** @type {Record<string, any>} */
134
+ const mcp = {};
135
+ for (const [id, server] of Object.entries(kinds.mcp)) {
136
+ if (isPlainObject(server)) mcp[id] = server;
137
+ }
138
+ // 空的不记。「一条都没写」和「没有这一项」是同一件事,
139
+ // 记成空对象只会让这份要提交的文件里多出一堆 {}",{}。
140
+ if (Object.keys(mcp).length > 0) kept.mcp = mcp;
141
+ }
142
+
143
+ if (isPlainObject(kinds.hooks)) {
144
+ /** @type {Record<string, any>} */
145
+ const hooks = {};
146
+ for (const [id, events] of Object.entries(kinds.hooks)) {
147
+ if (!isPlainObject(events)) continue;
148
+ /** @type {Record<string, any>} */
149
+ const ev = {};
150
+ for (const [name, entries] of Object.entries(events)) {
151
+ if (Array.isArray(entries)) ev[name] = entries;
152
+ }
153
+ if (Object.keys(ev).length > 0) hooks[id] = ev;
154
+ }
155
+ if (Object.keys(hooks).length > 0) kept.hooks = hooks;
156
+ }
157
+
158
+ if (Object.keys(kept).length > 0) out[tool] = kept;
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /**
164
+ * 读记录。文件不存在、坏了、版本不认识,一律当作「没有记录」——
165
+ * 那是保守方向:没有记录时什么都不会删。
166
+ *
167
+ * @param {string} projectRoot
168
+ * @returns {{usable: boolean, reason: string|null, installed: string[], scripts: string[], merged: Record<string, any>}}
169
+ */
170
+ export function readRecord(projectRoot) {
171
+ /** @type {{usable: boolean, reason: string|null, installed: string[], scripts: string[], merged: Record<string, any>}} */
172
+ const empty = { usable: false, reason: null, installed: [], scripts: [], merged: {} };
173
+
174
+ const p = recordPath(projectRoot);
175
+ if (!fs.existsSync(p)) return empty;
176
+
177
+ /** @type {any} */
178
+ let raw;
179
+ try {
180
+ raw = JSON.parse(fs.readFileSync(p, 'utf8'));
181
+ } catch (e) {
182
+ return {
183
+ ...empty,
184
+ reason: `${RECORD_REL} 不是合法的 JSON(${/** @type {Error} */ (e).message}),本次按「没有记录」处理`,
185
+ };
186
+ }
187
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
188
+ return { ...empty, reason: `${RECORD_REL} 的顶层必须是一个对象,本次按「没有记录」处理` };
189
+ }
190
+ if (raw.schemaVersion !== RECORD_SCHEMA) {
191
+ return {
192
+ ...empty,
193
+ reason:
194
+ `${RECORD_REL} 的 schemaVersion 是 ${JSON.stringify(raw.schemaVersion)},` +
195
+ `本工具只支持 ${RECORD_SCHEMA},本次按「没有记录」处理`,
196
+ };
197
+ }
198
+
199
+ // merged 缺字段(老记录)→ {}:`.agent-sync.json` 要提交进每个项目的版本库,
200
+ // 不能因为加了个字段就要求所有人先迁移一遍。
201
+ return {
202
+ usable: true,
203
+ reason: null,
204
+ installed: strList(raw.installed),
205
+ scripts: strList(raw.scripts),
206
+ merged: mergedMap(raw.merged),
207
+ };
208
+ }
209
+
210
+ /**
211
+ * 算出「本工具以前装的、现在不需要了」的东西。
212
+ *
213
+ * 判定只需要两条,都来自明面:
214
+ * - 记录里说装过
215
+ * - 本次不再选中,且没被 `protect` 锁住
216
+ *
217
+ * 本地已经不存在的不返回——它本来就没了,新记录里自然也不会再有。
218
+ *
219
+ * @param {string} projectRoot
220
+ * @param {{installed: string[], scripts: string[]}} record
221
+ * @param {{items: string[], scripts: string[], protect?: string[]}} wanted
222
+ * @returns {{key: string, abs: string}[]}
223
+ */
224
+ export function planRemovals(projectRoot, record, wanted) {
225
+ const protectedKeys = new Set(wanted.protect ?? []);
226
+ /** @type {{key: string, abs: string}[]} */
227
+ const out = [];
228
+
229
+ for (const key of record.installed) {
230
+ if (wanted.items.includes(key) || protectedKeys.has(key)) continue;
231
+ const { kind, id } = parseItem(key);
232
+ const abs = projectItemPath(projectRoot, kind, id);
233
+ if (fs.existsSync(abs)) out.push({ key, abs });
234
+ }
235
+
236
+ for (const rel of record.scripts) {
237
+ const key = `script:${rel}`;
238
+ if (wanted.scripts.includes(rel) || protectedKeys.has(key)) continue;
239
+ const abs = scriptPath(projectRoot, rel);
240
+ if (fs.existsSync(abs)) out.push({ key, abs });
241
+ }
242
+
243
+ return out;
244
+ }
245
+
246
+ /**
247
+ * 给本次装好的内容拍一张记录快照。
248
+ *
249
+ * 只记磁盘上确实存在的——记了装不上的,下次会被误当成「需要清理」。
250
+ * `projectRoot` 就是为了这个存在性检查(以及让调用方不必自己拼路径)。
251
+ *
252
+ * `merged` 不是从文件系统推出来的——它记的是「我们往别人文件里写了什么」,
253
+ * 只能由合并器算好后传进来。默认 `{}`:没有合并能力的那条路径不必关心它。
254
+ *
255
+ * @param {string} projectRoot @param {string[]} items @param {string[]} scripts
256
+ * @param {Record<string, any>} [merged]
257
+ */
258
+ export function snapshotRecord(projectRoot, items, scripts, merged = {}) {
259
+ /** @type {string[]} */
260
+ const installed = [];
261
+ for (const spec of items) {
262
+ try {
263
+ const { kind, id } = parseItem(spec);
264
+ if (fs.existsSync(projectItemPath(projectRoot, kind, id))) installed.push(spec);
265
+ } catch {
266
+ // 名字都解析不了就更不该记
267
+ }
268
+ }
269
+
270
+ /** @type {string[]} */
271
+ const s = [];
272
+ for (const rel of scripts) {
273
+ if (fs.existsSync(scriptPath(projectRoot, rel))) s.push(rel);
274
+ }
275
+
276
+ return {
277
+ schemaVersion: RECORD_SCHEMA,
278
+ installed: strList(installed),
279
+ scripts: strList(s),
280
+ merged: mergedMap(merged),
281
+ };
282
+ }
283
+
284
+ /**
285
+ * 写记录。内容没变就不碰文件。
286
+ *
287
+ * ⚠️ 这里是**整份重建**的:任何字段在这个字面量里漏掉,下次 sync 就会被静默丢弃。
288
+ * 加字段时 `readRecord` / `snapshotRecord` / 这里 / `carryOver` 四处都要过一遍。
289
+ *
290
+ * @param {string} projectRoot
291
+ * @param {{installed: string[], scripts: string[], merged?: Record<string, any>}} snapshot
292
+ * @param {{dryRun?: boolean}} [opts]
293
+ */
294
+ export function writeRecord(projectRoot, snapshot, opts = {}) {
295
+ const p = recordPath(projectRoot);
296
+ const text = `${JSON.stringify(
297
+ {
298
+ schemaVersion: RECORD_SCHEMA,
299
+ installed: strList(snapshot.installed),
300
+ scripts: strList(snapshot.scripts),
301
+ merged: mergedMap(snapshot.merged),
302
+ },
303
+ null,
304
+ 2,
305
+ )}\n`;
306
+
307
+ /** @type {string|null} */
308
+ let current = null;
309
+ try {
310
+ current = fs.readFileSync(p, 'utf8');
311
+ } catch {
312
+ // 不存在,下面照常写
313
+ }
314
+
315
+ const changed = eol(current) !== eol(text);
316
+ if (changed && !opts.dryRun) {
317
+ fs.mkdirSync(path.dirname(p), { recursive: true });
318
+ fs.writeFileSync(p, text);
319
+ }
320
+ return { changed, path: p };
321
+ }
322
+
323
+ /**
324
+ * 这次没删成的条目要留在记录里继续跟踪。
325
+ *
326
+ * 不能一删了之:它们既不属于本次选择,也不属于用户从头写的东西,
327
+ * 直接从记录里抹掉就等于失联——下次同步再也说不出「这是本工具装的」,
328
+ * 用户漏看一次就永远找不回来了。
329
+ *
330
+ * `merged` 原样带过,这里不碰它——它是「往别人文件里写了什么」,撤销失败的
331
+ * 那部分该不该留着,只有合并器自己判断得了。调用方把它放进 snapshot 就是了。
332
+ *
333
+ * @param {{installed: string[], scripts: string[], merged?: Record<string, any>}} snapshot
334
+ * @param {string[]} keys 形如 "skill:x" 或 "script:a.sh"
335
+ */
336
+ export function carryOver(snapshot, keys) {
337
+ for (const key of keys) {
338
+ if (key.startsWith('script:')) {
339
+ const rel = key.slice('script:'.length);
340
+ if (!snapshot.scripts.includes(rel)) snapshot.scripts.push(rel);
341
+ } else if (!snapshot.installed.includes(key)) {
342
+ snapshot.installed.push(key);
343
+ }
344
+ }
345
+ snapshot.installed = strList(snapshot.installed);
346
+ snapshot.scripts = strList(snapshot.scripts);
347
+ return snapshot;
348
+ }
349
+
350
+ /**
351
+ * 这条记录跟踪的是不是某个键(`"skill:x"` 或 `"script:a.sh"`)。
352
+ * @param {{installed: string[], scripts: string[]}} record @param {string} key
353
+ */
354
+ export function isTracked(record, key) {
355
+ return key.startsWith('script:')
356
+ ? record.scripts.includes(key.slice('script:'.length))
357
+ : record.installed.includes(key);
358
+ }
359
+
360
+ /**
361
+ * 记录里跟踪的条目,本地还在不在。
362
+ * 给 status 用——它没有内容仓库,判断不了「该不该装」,但报得出「还在不在」。
363
+ *
364
+ * @param {string} projectRoot
365
+ * @param {{installed: string[], scripts: string[]}} record
366
+ */
367
+ export function checkRecord(projectRoot, record) {
368
+ /** @type {{key: string, abs: string, exists: boolean}[]} */
369
+ const out = [];
370
+
371
+ const check = (key, abs) => out.push({ key, abs, exists: fs.existsSync(abs) });
372
+
373
+ for (const key of record.installed) {
374
+ const { kind, id } = parseItem(key);
375
+ check(key, projectItemPath(projectRoot, kind, id));
376
+ }
377
+ for (const rel of record.scripts) check(`script:${rel}`, scriptPath(projectRoot, rel));
378
+
379
+ return out;
380
+ }
package/lib/source.js ADDED
@@ -0,0 +1,240 @@
1
+ // @ts-check
2
+ import { spawnSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import process from 'node:process';
7
+
8
+ /**
9
+ * 判断 `content` 给的是 git 地址还是本地路径。
10
+ *
11
+ * 先排除本地路径的写法(Windows 盘符、绝对路径、相对路径),再看协议前缀。
12
+ * 顺序很重要:`C:/x/foo.git` 以 .git 结尾,但它显然是本地路径。
13
+ *
14
+ * @param {string} value
15
+ */
16
+ export function isGitUrl(value) {
17
+ const s = value.trim();
18
+ if (s === '') return false;
19
+
20
+ // 本地路径的三种写法:盘符、POSIX 绝对路径、相对路径
21
+ if (/^[a-zA-Z]:[\\/]/.test(s)) return false;
22
+ if (s.startsWith('/') || s.startsWith('\\')) return false;
23
+ if (s.startsWith('.')) return false;
24
+
25
+ return (
26
+ /^(https?|git|ssh|file):\/\//i.test(s) ||
27
+ /^[^\s/@]+@[^\s/]+:/.test(s) || // scp 风格:git@host:path
28
+ s.endsWith('.git')
29
+ );
30
+ }
31
+
32
+ /** git 是否可用 */
33
+ export function hasGit() {
34
+ const res = spawnSync('git', ['--version'], { encoding: 'utf8' });
35
+ return res.status === 0;
36
+ }
37
+
38
+ /**
39
+ * 把一个 git 仓库的某个 ref 取到临时目录。
40
+ *
41
+ * 取完即用、用完即删——不做本地缓存。多花的是一次 clone 的时间(内容仓库通常很小),
42
+ * 换来的是没有「缓存过期」这类说不清的状态。
43
+ *
44
+ * **`GIT_TERMINAL_PROMPT=0` 是必须的**:否则遇到需要凭据的仓库时 git 会停下来等输入,
45
+ * 在 CI 或 postinstall 里就是永久挂起——和交互式提示一样危险。
46
+ *
47
+ * @param {{url: string, ref?: string|null}} opts
48
+ * @returns {{root: string, cleanup: () => void}}
49
+ */
50
+ export function fetchRepo({ url, ref }) {
51
+ if (!hasGit()) {
52
+ throw new Error('找不到 git 命令。请先安装 git,或把 content 指向一个本地已克隆的目录。');
53
+ }
54
+
55
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-syncer-src-'));
56
+ const dest = path.join(tmpRoot, 'repo');
57
+ const cleanup = () => fs.rmSync(tmpRoot, { recursive: true, force: true });
58
+
59
+ const baseEnv = {
60
+ ...process.env,
61
+ GIT_TERMINAL_PROMPT: '0', // 绝不等待输入
62
+ GIT_ASKPASS: 'echo',
63
+ };
64
+
65
+ /** @param {string[]} args */
66
+ const run = (args) =>
67
+ spawnSync('git', args, {
68
+ encoding: 'utf8',
69
+ env: baseEnv,
70
+ // 不用 inherit:clone 的进度输出对使用者没有价值,失败时我们再完整打印 stderr
71
+ stdio: ['ignore', 'pipe', 'pipe'],
72
+ });
73
+
74
+ /** @type {string[]} */
75
+ const cloneArgs = ['clone', '--quiet', '--depth', '1'];
76
+ if (ref) cloneArgs.push('--branch', ref);
77
+ cloneArgs.push('--', url, dest);
78
+
79
+ let res = run(cloneArgs);
80
+
81
+ // `--branch` 只认分支名和标签名,不认裸 SHA。这时退回完整克隆再检出。
82
+ // 失败时可能留下半个目标目录,重试前必须先清掉,否则克隆会因「目录非空」再失败一次。
83
+ if (res.status !== 0 && ref) {
84
+ fs.rmSync(dest, { recursive: true, force: true });
85
+ const full = run(['clone', '--quiet', '--', url, dest]);
86
+ res =
87
+ full.status === 0
88
+ ? run(['-C', dest, 'checkout', '--quiet', '--detach', ref])
89
+ : full;
90
+ }
91
+
92
+ if (res.status !== 0) {
93
+ cleanup();
94
+ const detail = (res.stderr || res.stdout || '').trim();
95
+ throw new Error(
96
+ `拉取失败:${url}${ref ? ` @ ${ref}` : ''}\n` +
97
+ (detail ? ` git 说:${detail}` : ' (git 没有输出更多信息)'),
98
+ );
99
+ }
100
+
101
+ return { root: dest, cleanup };
102
+ }
103
+
104
+ /**
105
+ * 列表类 git 调用的超时(毫秒)。
106
+ *
107
+ * 取 refs 是**问了才用**的一步:内容仓库连不上时,用户宁可看到「跳过这一步」
108
+ * 也不愿意对着一个不动的光标等下去。凭据那条路已经由 GIT_TERMINAL_PROMPT=0 堵死,
109
+ * 这里堵的是「连得上但不回话」——网络黑洞、被墙的 host、挂掉的 git server。
110
+ */
111
+ const REF_LIST_TIMEOUT_MS = 15_000;
112
+
113
+ /**
114
+ * 解析 `git` 列出来的 ref 行,分成分支和标签。
115
+ *
116
+ * 两种来源共用:`ls-remote` 给的是 `<sha>\t<ref>`,`for-each-ref` 给的是裸的
117
+ * `<ref>`。所以只认「最后一个字段」,前缀对不上的一律丢掉。
118
+ *
119
+ * 标签要去掉 `^{}` 后缀:注解标签在 `ls-remote` 里会出现两行(标签本身和它
120
+ * 指向的提交),不处理的话列表里会多出一个 `v1^{}`。
121
+ *
122
+ * @param {string} text
123
+ * @returns {{branches: string[], tags: string[]}}
124
+ */
125
+ function parseRefs(text) {
126
+ /** @type {string[]} */
127
+ const branches = [];
128
+ /** @type {string[]} */
129
+ const tags = [];
130
+
131
+ for (const line of text.split('\n')) {
132
+ const trimmed = line.trim();
133
+ if (trimmed === '') continue;
134
+ const parts = trimmed.split(/\s+/);
135
+ const ref = parts[parts.length - 1];
136
+ if (ref.startsWith('refs/heads/')) {
137
+ branches.push(ref.slice('refs/heads/'.length));
138
+ } else if (ref.startsWith('refs/tags/')) {
139
+ const name = ref.slice('refs/tags/'.length).replace(/\^\{\}$/, '');
140
+ if (name !== '' && !tags.includes(name)) tags.push(name);
141
+ }
142
+ }
143
+
144
+ return { branches, tags };
145
+ }
146
+
147
+ /**
148
+ * 本地路径配了 `ref` 时该说的那句话。
149
+ *
150
+ * `sync` 对本地路径读的是那个目录的**工作树**(这正是本地路径「改完内容立刻能试」
151
+ * 的来由),`ref` 一个字都用不上。写进配置却不生效,比不写更糟——用户会以为版本
152
+ * 被钉住了,实际拿到的还是他当前检出的那份,而且**全程没有任何提示**。
153
+ *
154
+ * 想钉版本就把 `content` 写成 git 地址(`file:///…` 也算,那会走 clone)。
155
+ *
156
+ * @param {string} source @param {string|null|undefined} ref
157
+ * @returns {string|null} 要说的话;没什么要说的返回 null
158
+ */
159
+ export function localRefWarning(source, ref) {
160
+ if (!ref || isGitUrl(source)) return null;
161
+ return (
162
+ `"ref": "${ref}" 对本地路径不生效——读的是那个目录的工作树,不是某个提交。` +
163
+ '要钉版本请把 content 写成 git 地址'
164
+ );
165
+ }
166
+
167
+ /**
168
+ * 列出内容仓库有哪些分支和标签。
169
+ *
170
+ * 远端走 `git ls-remote`(**不克隆**,只问一句),本地路径走 `git for-each-ref`。
171
+ * 本地路径不是 git 仓库、git 没装、离线、要凭据、超时——**一律返回 null,
172
+ * 不抛错**。这一条是刻意的:调用方(init)问这个问题只是为了给用户一个下拉列表,
173
+ * 取不到就该安静跳过,绝不能让「列不出 refs」把整个流程拦下来。
174
+ *
175
+ * `GIT_TERMINAL_PROMPT=0` 与 fetchRepo 同一个理由:要密码的仓库必须当场失败,
176
+ * 不能停下来等人输入——CI 里那就是永久挂起。
177
+ *
178
+ * @param {{source: string, cwd?: string}} opts `source` 是本地路径或 git 地址
179
+ * @returns {{branches: string[], tags: string[]}|null}
180
+ */
181
+ export function listRefs({ source, cwd = process.cwd() }) {
182
+ if (typeof source !== 'string' || source.trim() === '') return null;
183
+ if (!hasGit()) return null;
184
+
185
+ const env = {
186
+ ...process.env,
187
+ GIT_TERMINAL_PROMPT: '0',
188
+ GIT_ASKPASS: 'echo',
189
+ };
190
+ const opts = {
191
+ encoding: /** @type {const} */ ('utf8'),
192
+ env,
193
+ stdio: /** @type {const} */ (['ignore', 'pipe', 'pipe']),
194
+ timeout: REF_LIST_TIMEOUT_MS,
195
+ };
196
+
197
+ const res = isGitUrl(source)
198
+ ? spawnSync('git', ['ls-remote', '--heads', '--tags', '--', source], opts)
199
+ : spawnSync('git', ['-C', path.resolve(cwd, source), 'for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/tags'], opts);
200
+
201
+ // timeout 时 spawnSync 会把 status 置成 null 并给出 error,一并当作「取不到」
202
+ if (res.error || res.status !== 0) return null;
203
+ return parseRefs(res.stdout ?? '');
204
+ }
205
+
206
+ /**
207
+ * 把分支和标签排成下拉列表里的顺序。纯函数,单独测。
208
+ *
209
+ * 顺序是有讲究的,不是随便排的:
210
+ *
211
+ * 1. `main`、`master` —— 用户十有八九要选的就是它,而且 main 在 master 前面
212
+ * (新仓库用 main,老仓库才是 master)
213
+ * 2. **标签** —— 想钉版本的人一眼能看到;这类名字(v1.2.3)和分支名不会混
214
+ * 3. 其它分支 —— 最不常选,垫底
215
+ *
216
+ * 组内按 `numeric` 的自然序排(v1.2 < v1.10,而不是字典序的 v1.10 < v1.2),
217
+ * 并且**排一次序**:输入顺序取决于 git 的返回顺序,那东西不稳定,而
218
+ * 下拉列表每次长得不一样会让人怀疑自己是不是点错了。
219
+ *
220
+ * 同名去重,保留先出现的那个——分支 `v1` 和标签 `v1` 撞名时按上面的优先级走。
221
+ *
222
+ * @param {{branches?: string[], tags?: string[]}} refs
223
+ * @returns {string[]}
224
+ */
225
+ export function orderRefs({ branches = [], tags = [] } = {}) {
226
+ // 自然序:不带 numeric 的话 v1.10 会排在 v1.2 前面,而标签里这种名字最常见
227
+ const cmp = (/** @type {string} */ a, /** @type {string} */ b) =>
228
+ a.localeCompare(b, 'en', { numeric: true });
229
+
230
+ const rank = (/** @type {string} */ name) => (name === 'main' ? 0 : name === 'master' ? 1 : 2);
231
+ const head = branches.filter((b) => rank(b) < 2).sort((a, b) => rank(a) - rank(b) || cmp(a, b));
232
+ const rest = branches.filter((b) => rank(b) === 2).sort(cmp);
233
+
234
+ /** @type {string[]} */
235
+ const out = [];
236
+ for (const name of [...head, ...[...tags].sort(cmp), ...rest]) {
237
+ if (name !== '' && !out.includes(name)) out.push(name);
238
+ }
239
+ return out;
240
+ }