@carllee1983/tagsmith 0.2.0 → 0.3.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/README.md +111 -8
- package/dist/cli/hooks.d.ts +5 -0
- package/dist/cli/hooks.js +99 -0
- package/dist/cli/index.js +32 -4
- package/dist/cli/merge-check.d.ts +4 -0
- package/dist/cli/merge-check.js +73 -0
- package/dist/core/merge-policy/match.d.ts +6 -0
- package/dist/core/merge-policy/match.js +15 -0
- package/dist/core/merge-policy/resolve.d.ts +7 -0
- package/dist/core/merge-policy/resolve.js +56 -0
- package/dist/core/merge-policy/schema.d.ts +14 -0
- package/dist/core/merge-policy/schema.js +53 -0
- package/dist/core/merge-policy/validate.d.ts +12 -0
- package/dist/core/merge-policy/validate.js +33 -0
- package/dist/git/git.d.ts +18 -0
- package/dist/git/git.js +75 -0
- package/package.json +1 -1
- package/schema.json +36 -1
package/README.md
CHANGED
|
@@ -1,28 +1,56 @@
|
|
|
1
1
|
# Tagsmith
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@carllee1983/tagsmith)
|
|
4
|
+
|
|
3
5
|
定義專案的 git tag 規格、檢視現有 tag,並安全地產生下一個 git tag——避免順序錯亂或格式不一致。
|
|
4
6
|
|
|
5
7
|
支援 **SemVer**、**CalVer** 與 **build number** 三種版本模型,tag 樣式可自訂(例如 `v{version}`、`release/{version}`)。
|
|
6
8
|
|
|
7
|
-
- 🏷️ **規格化** —
|
|
9
|
+
- 🏷️ **規格化** — 用 `.tagsmith.json` 定義全專案的 tag 樣式與版本模型(可選)
|
|
8
10
|
- 🔍 **可檢視** — 依語義排序列出 tag,標示格式 / 順序 / 重複異常
|
|
9
11
|
- 🛡️ **防呆** — 建立前驗證格式、版本可解析、嚴格遞增、tag 不重複
|
|
12
|
+
- 🚀 **零設定** — 無設定檔時自動以 semver 推斷 pattern,讀 repo 既有 tag 即可用
|
|
10
13
|
- 🧩 **可擴充** — 版本模型走介面抽象,新增不動核心邏輯
|
|
14
|
+
- 🚧 **合併護欄** — 以 `mergePolicy` 限制受保護分支的合併來源(白 / 黑名單),由 git hook 自動把關
|
|
11
15
|
|
|
12
16
|
## 安裝
|
|
13
17
|
|
|
14
18
|
```bash
|
|
15
|
-
|
|
19
|
+
# 全域安裝(指令名稱仍為 tagsmith)
|
|
20
|
+
npm install -g @carllee1983/tagsmith
|
|
21
|
+
|
|
16
22
|
# 或免安裝直接執行
|
|
17
|
-
npx tagsmith <command>
|
|
23
|
+
npx @carllee1983/tagsmith <command>
|
|
24
|
+
|
|
25
|
+
# 專案相依(CI / husky hook 常用)
|
|
26
|
+
npm install -D @carllee1983/tagsmith
|
|
27
|
+
# 裝在本機後可直接:npx tagsmith <command>
|
|
18
28
|
```
|
|
19
29
|
|
|
30
|
+
npm:[https://www.npmjs.com/package/@carllee1983/tagsmith](https://www.npmjs.com/package/@carllee1983/tagsmith)
|
|
31
|
+
|
|
20
32
|
需求:Node.js ≥ 18、git。
|
|
21
33
|
|
|
22
34
|
## 快速開始
|
|
23
35
|
|
|
36
|
+
### 零設定(無 `.tagsmith.json`)
|
|
37
|
+
|
|
38
|
+
已有 semver 風格 tag(如 `v0.1.0`)的 repo 可直接使用:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
tagsmith list # 檢視既有 tag
|
|
42
|
+
tagsmith next # 預覽下一個 tag(預設 patch bump)
|
|
43
|
+
tagsmith next --level minor # 例如 v0.1.0 → v0.2.0
|
|
44
|
+
tagsmith create --push # 建立並推送
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
無設定檔時預設 semver、`v{version}` pattern;會從既有 tag 自動推斷格式(如 `{version}`)。
|
|
48
|
+
團隊協作建議仍執行 `init` 並 commit `.tagsmith.json` 以固定規格。
|
|
49
|
+
|
|
50
|
+
### 完整流程(自訂規格)
|
|
51
|
+
|
|
24
52
|
```bash
|
|
25
|
-
# 1. 在 repo 內定義 tag
|
|
53
|
+
# 1. 在 repo 內定義 tag 規格(互動式,可選)
|
|
26
54
|
tagsmith init
|
|
27
55
|
|
|
28
56
|
# 不熟指令?走一次互動式導覽
|
|
@@ -99,7 +127,7 @@ tagsmith create --level minor -m "Release 1.2.0" --push
|
|
|
99
127
|
|
|
100
128
|
Tagsmith 載入時會自動將其視為一條名為 `default` 的單線設定;現有使用者**零修改**即可繼續使用。
|
|
101
129
|
|
|
102
|
-
可在檔案加上 `"$schema": "./node_modules/tagsmith/schema.json"` 取得編輯器補全與驗證。
|
|
130
|
+
可在檔案加上 `"$schema": "./node_modules/@carllee1983/tagsmith/schema.json"` 取得編輯器補全與驗證。
|
|
103
131
|
|
|
104
132
|
### 三種版本模型
|
|
105
133
|
|
|
@@ -154,7 +182,7 @@ Tagsmith 載入時會自動將其視為一條名為 `default` 的單線設定;
|
|
|
154
182
|
## 指令
|
|
155
183
|
|
|
156
184
|
### `tagsmith init`
|
|
157
|
-
互動式產生 `.tagsmith.json
|
|
185
|
+
互動式產生 `.tagsmith.json`(**可選**;zero-config 模式下可略過,團隊協作仍建議 commit 設定檔)。
|
|
158
186
|
|
|
159
187
|
| 旗標 | 說明 |
|
|
160
188
|
|------|------|
|
|
@@ -296,14 +324,89 @@ tagsmith list --all
|
|
|
296
324
|
## 搭配 husky 守 tag
|
|
297
325
|
|
|
298
326
|
可用 git `pre-push` hook 在推送時自動驗證 tag,擋下不符規格者。
|
|
299
|
-
詳見 [docs/husky-pre-push.md](docs/husky-pre-push.md)
|
|
327
|
+
詳見 [docs/husky-pre-push.md](docs/husky-pre-push.md)(需安裝 `@carllee1983/tagsmith`)。
|
|
328
|
+
|
|
329
|
+
## 合併政策(merge policy)
|
|
330
|
+
|
|
331
|
+
除了管 tag,Tagsmith 也能當 **git 工作流護欄**:限制哪些分支可以合併進受保護分支,
|
|
332
|
+
避免誤把 `develop`、`feature/*` 直接併進 `main`。規則寫在 `.tagsmith.json` 的
|
|
333
|
+
`mergePolicy` 區塊,由本機 git hook(`prepare-commit-msg` / `post-merge`)自動執行——
|
|
334
|
+
**純本機檢查,不涉及 PR 或遠端 server 端政策**。
|
|
335
|
+
|
|
336
|
+
### 設定
|
|
337
|
+
|
|
338
|
+
```jsonc
|
|
339
|
+
{
|
|
340
|
+
"pattern": "v{version}", // 既有 tag 設定不受影響
|
|
341
|
+
"model": { "type": "semver" },
|
|
342
|
+
"initialVersion": "0.1.0",
|
|
343
|
+
|
|
344
|
+
"mergePolicy": {
|
|
345
|
+
"protectedBranches": {
|
|
346
|
+
"develop": { "allow": ["main"] }, // 白名單:只准 main 併入
|
|
347
|
+
"main": { "deny": ["develop", "testing", "feature/*"] }, // 黑名單:擋這些
|
|
348
|
+
"testing": { "deny": ["develop", "main"] }
|
|
349
|
+
},
|
|
350
|
+
"onUnknownSource": "block" // 無法解析來源時:block(預設)| allow
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
規則:
|
|
356
|
+
|
|
357
|
+
- `mergePolicy` **選配**,缺省即關閉,對既有使用者完全向後相容。
|
|
358
|
+
- `protectedBranches` 的 key 是受保護分支名;**只有目前所在分支落在清單時才檢查**,
|
|
359
|
+
其餘分支一律放行。
|
|
360
|
+
- 每個受保護分支**二選一**:
|
|
361
|
+
- `allow`(白名單)— 只允許名單內來源合併進來,其餘封鎖。
|
|
362
|
+
- `deny`(黑名單)— 名單內來源封鎖,其餘放行。
|
|
363
|
+
- 同時提供或兩者皆缺 → 設定驗證錯誤。
|
|
364
|
+
- 來源比對支援萬用字元:`*` 比對任意字元(**含 `/`**,可跨多層),`?` 比對單一字元;
|
|
365
|
+
例如 `feature/*`、`hotfix/*`。
|
|
366
|
+
- `onUnknownSource` — 無法解析合併來源分支時的行為,預設 `block`。
|
|
367
|
+
|
|
368
|
+
### 安裝 hooks
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
npm install -D @carllee1983/tagsmith # 先把套件裝進專案
|
|
372
|
+
npx tagsmith hooks install # 寫入 git hooks
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
`hooks install` 會偵測 hook 機制:有 `.husky/` 目錄則寫入 husky,否則寫入 `.git/hooks/`。
|
|
376
|
+
寫入的 hook 只負責呼叫 `tagsmith merge-check`,內容帶有 `# tagsmith-merge-policy (managed)`
|
|
377
|
+
標記。若目標位置已有非 tagsmith 管理的 hook,預設中止(**不寫入任何檔案**),需加 `--force` 覆寫。
|
|
378
|
+
移除用 `tagsmith hooks uninstall`(只移除帶標記的檔案,不動其他 hook)。
|
|
379
|
+
|
|
380
|
+
### 攔截行為
|
|
381
|
+
|
|
382
|
+
當合併違反政策時:
|
|
383
|
+
|
|
384
|
+
- **建立 merge commit**(`prepare-commit-msg`,尚未 commit)— 無法乾淨回滾,直接中止,
|
|
385
|
+
提示 `git merge --abort`。
|
|
386
|
+
- **fast-forward 合併**(`post-merge`,HEAD 已前進)— 自動 `git reset --hard ORIG_HEAD`
|
|
387
|
+
回到合併前狀態。
|
|
388
|
+
|
|
389
|
+
訊息會列出 target 分支、source 分支與封鎖原因。緊急時可用環境變數略過檢查:
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
TAGSMITH_SKIP=1 git merge ... # 略過一次(緊急用)
|
|
393
|
+
HUSKY=0 git merge ... # 同樣略過
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### 相關指令
|
|
397
|
+
|
|
398
|
+
| 指令 | 說明 |
|
|
399
|
+
|------|------|
|
|
400
|
+
| `tagsmith hooks install [--force]` | 安裝 merge-policy git hooks(`--force` 覆寫既有非 tagsmith hook) |
|
|
401
|
+
| `tagsmith hooks uninstall` | 移除 tagsmith 管理的 hooks |
|
|
402
|
+
| `tagsmith merge-check [--mode <merge-head\|post-merge>]` | 由 hook 呼叫,套用政策;非日常手動輸入 |
|
|
300
403
|
|
|
301
404
|
## 結束代碼
|
|
302
405
|
|
|
303
406
|
| 代碼 | 意義 |
|
|
304
407
|
|:---:|------|
|
|
305
408
|
| `0` | 成功(含 `--dry-run`) |
|
|
306
|
-
| `1` |
|
|
409
|
+
| `1` | 失敗:非 git repo、驗證未通過、git 指令錯誤等(訊息走 stderr) |
|
|
307
410
|
|
|
308
411
|
## 設計
|
|
309
412
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// src/cli/hooks.ts
|
|
2
|
+
import { access, chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { revParse } from "../git/git.js";
|
|
5
|
+
import { info, printError, success, warn } from "./ui.js";
|
|
6
|
+
const MARKER = "# tagsmith-merge-policy (managed)";
|
|
7
|
+
const HOOKS = [
|
|
8
|
+
{
|
|
9
|
+
name: "prepare-commit-msg",
|
|
10
|
+
body: [
|
|
11
|
+
"#!/usr/bin/env sh",
|
|
12
|
+
MARKER,
|
|
13
|
+
'case "$2" in',
|
|
14
|
+
" merge) npx --no -- tagsmith merge-check --mode merge-head || exit $? ;;",
|
|
15
|
+
"esac",
|
|
16
|
+
"",
|
|
17
|
+
].join("\n"),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "post-merge",
|
|
21
|
+
body: [
|
|
22
|
+
"#!/usr/bin/env sh",
|
|
23
|
+
MARKER,
|
|
24
|
+
"npx --no -- tagsmith merge-check --mode post-merge || exit $?",
|
|
25
|
+
"",
|
|
26
|
+
].join("\n"),
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
/** Resolve the directory hooks should be written to: .husky if present, else .git/hooks. */
|
|
30
|
+
async function resolveHooksDir(cwd) {
|
|
31
|
+
if (await existsFile(path.join(cwd, ".husky")))
|
|
32
|
+
return path.join(cwd, ".husky");
|
|
33
|
+
// `git rev-parse --git-dir` returns the git dir (relative or absolute).
|
|
34
|
+
const raw = (await revParse({ cwd }, "--git-dir")).trim();
|
|
35
|
+
const gitDir = path.isAbsolute(raw) ? raw : path.join(cwd, raw);
|
|
36
|
+
return path.join(gitDir, "hooks");
|
|
37
|
+
}
|
|
38
|
+
async function existsFile(file) {
|
|
39
|
+
try {
|
|
40
|
+
await access(file);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function runHooksInstall(cwd, flags) {
|
|
48
|
+
try {
|
|
49
|
+
const dir = await resolveHooksDir(cwd);
|
|
50
|
+
await mkdir(dir, { recursive: true });
|
|
51
|
+
// Pre-flight: refuse before writing anything if any hook is foreign.
|
|
52
|
+
for (const hook of HOOKS) {
|
|
53
|
+
const file = path.join(dir, hook.name);
|
|
54
|
+
if (await existsFile(file)) {
|
|
55
|
+
const current = await readFile(file, "utf8");
|
|
56
|
+
if (!current.includes(MARKER) && !flags.force) {
|
|
57
|
+
printError(`${hook.name} already exists and is not tagsmith-managed. Re-run with --force to overwrite.`);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const hook of HOOKS) {
|
|
63
|
+
const file = path.join(dir, hook.name);
|
|
64
|
+
await writeFile(file, hook.body, "utf8");
|
|
65
|
+
await chmod(file, 0o755);
|
|
66
|
+
success(`installed ${path.relative(cwd, file)}`);
|
|
67
|
+
}
|
|
68
|
+
info("");
|
|
69
|
+
info("merge-policy hooks installed. Configure rules under `mergePolicy` in .tagsmith.json.");
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
printError(err);
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export async function runHooksUninstall(cwd) {
|
|
78
|
+
try {
|
|
79
|
+
const dir = await resolveHooksDir(cwd);
|
|
80
|
+
for (const hook of HOOKS) {
|
|
81
|
+
const file = path.join(dir, hook.name);
|
|
82
|
+
if (!(await existsFile(file)))
|
|
83
|
+
continue;
|
|
84
|
+
const current = await readFile(file, "utf8");
|
|
85
|
+
if (current.includes(MARKER)) {
|
|
86
|
+
await rm(file);
|
|
87
|
+
success(`removed ${path.relative(cwd, file)}`);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
warn(`skipped ${hook.name} (not tagsmith-managed)`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
printError(err);
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import { Command } from "commander";
|
|
3
4
|
import { runInit } from "./init.js";
|
|
4
5
|
import { runList } from "./list.js";
|
|
@@ -6,14 +7,18 @@ import { runNext } from "./next.js";
|
|
|
6
7
|
import { runCreate } from "./create.js";
|
|
7
8
|
import { runGuide } from "./guide.js";
|
|
8
9
|
import { runCheck } from "./check.js";
|
|
10
|
+
import { runMergeCheck } from "./merge-check.js";
|
|
11
|
+
import { runHooksInstall, runHooksUninstall } from "./hooks.js";
|
|
9
12
|
import { printError } from "./ui.js";
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const { version } = require("../../package.json");
|
|
10
15
|
const program = new Command();
|
|
11
16
|
program
|
|
12
17
|
.name("tagsmith")
|
|
13
18
|
.description("Define git tag specs, view tags, and generate the next git tag safely.")
|
|
14
|
-
.version(
|
|
15
|
-
program.addHelpText("beforeAll", "\n Tagsmith — define a tag spec, then safely compute and create git tags.\n" +
|
|
16
|
-
"
|
|
19
|
+
.version(version);
|
|
20
|
+
program.addHelpText("beforeAll", "\n Tagsmith (@carllee1983/tagsmith) — define a tag spec, then safely compute and create git tags.\n" +
|
|
21
|
+
" No config yet? Try `tagsmith next` (zero-config semver), `tagsmith init`, or `tagsmith guide`.\n");
|
|
17
22
|
program.addHelpText("after", `
|
|
18
23
|
Examples:
|
|
19
24
|
$ tagsmith init Define the tag spec (interactive)
|
|
@@ -26,7 +31,7 @@ Examples:
|
|
|
26
31
|
`);
|
|
27
32
|
program
|
|
28
33
|
.command("init")
|
|
29
|
-
.description("Create a .tagsmith.json tag spec for this repo")
|
|
34
|
+
.description("Create a .tagsmith.json tag spec for this repo (optional; zero-config works without it)")
|
|
30
35
|
.option("--pattern <pattern>", "tag pattern, must contain {version}")
|
|
31
36
|
.option("--model <type>", "version model: semver | calver | build")
|
|
32
37
|
.option("--initial-version <version>", "initial version")
|
|
@@ -90,6 +95,29 @@ Examples:
|
|
|
90
95
|
$ tagsmith create --dry-run Preview without creating
|
|
91
96
|
$ tagsmith create --tag release Create the next tag on a named tag line
|
|
92
97
|
`);
|
|
98
|
+
program
|
|
99
|
+
.command("merge-check")
|
|
100
|
+
.description("Enforce the mergePolicy for a protected branch (used by git hooks)")
|
|
101
|
+
.option("--mode <mode>", "hook context: merge-head | post-merge", "merge-head")
|
|
102
|
+
.action(async (opts) => {
|
|
103
|
+
process.exitCode = await runMergeCheck(process.cwd(), { mode: opts.mode });
|
|
104
|
+
});
|
|
105
|
+
const hooks = program
|
|
106
|
+
.command("hooks")
|
|
107
|
+
.description("Manage tagsmith git hooks (merge policy enforcement)");
|
|
108
|
+
hooks
|
|
109
|
+
.command("install")
|
|
110
|
+
.description("Install merge-policy git hooks into this repo")
|
|
111
|
+
.option("--force", "overwrite existing non-tagsmith hooks")
|
|
112
|
+
.action(async (opts) => {
|
|
113
|
+
process.exitCode = await runHooksInstall(process.cwd(), { force: opts.force });
|
|
114
|
+
});
|
|
115
|
+
hooks
|
|
116
|
+
.command("uninstall")
|
|
117
|
+
.description("Remove tagsmith-managed git hooks")
|
|
118
|
+
.action(async () => {
|
|
119
|
+
process.exitCode = await runHooksUninstall(process.cwd());
|
|
120
|
+
});
|
|
93
121
|
program.parseAsync(process.argv).catch((err) => {
|
|
94
122
|
printError(err);
|
|
95
123
|
process.exitCode = 1;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// src/cli/merge-check.ts
|
|
2
|
+
import { loadMergePolicy } from "../core/merge-policy/schema.js";
|
|
3
|
+
import { validateMerge } from "../core/merge-policy/validate.js";
|
|
4
|
+
import { resolveFfSource, resolveFromMergeHead, } from "../core/merge-policy/resolve.js";
|
|
5
|
+
import { currentBranch, isAncestor, parentCount, resetHard, revParse, revParseVerify, } from "../git/git.js";
|
|
6
|
+
import { color, info, printError } from "./ui.js";
|
|
7
|
+
function skipRequested() {
|
|
8
|
+
return process.env.HUSKY === "0" || process.env.TAGSMITH_SKIP === "1";
|
|
9
|
+
}
|
|
10
|
+
export async function runMergeCheck(cwd, flags) {
|
|
11
|
+
if (skipRequested())
|
|
12
|
+
return 0;
|
|
13
|
+
try {
|
|
14
|
+
const policy = await loadMergePolicy(cwd);
|
|
15
|
+
if (!policy)
|
|
16
|
+
return 0;
|
|
17
|
+
// fallback guards programmatic callers; commander supplies the CLI default
|
|
18
|
+
const mode = flags.mode ?? "merge-head";
|
|
19
|
+
const current = await currentBranch({ cwd });
|
|
20
|
+
if (current === "") {
|
|
21
|
+
printError("merge-policy: detached HEAD — branch name cannot be determined.");
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
if (!(current in policy.protectedBranches))
|
|
25
|
+
return 0;
|
|
26
|
+
let source;
|
|
27
|
+
let rollback = null;
|
|
28
|
+
if (mode === "post-merge") {
|
|
29
|
+
rollback = await revParseVerify({ cwd }, "ORIG_HEAD");
|
|
30
|
+
const newHead = await revParse({ cwd }, "HEAD");
|
|
31
|
+
if (!rollback || rollback === newHead)
|
|
32
|
+
return 0;
|
|
33
|
+
// A merge commit authored by this merge (>=2 parents whose first parent
|
|
34
|
+
// is the pre-merge HEAD) was already validated by the merge-head hook;
|
|
35
|
+
// its source branch no longer points at HEAD, so re-checking here would
|
|
36
|
+
// resolve no source and wrongly roll the merge back. Only true
|
|
37
|
+
// fast-forwards (which create no commit and skip merge-head) need
|
|
38
|
+
// post-merge enforcement. This also subsumes the octopus case.
|
|
39
|
+
if ((await parentCount({ cwd }, "HEAD")) >= 2) {
|
|
40
|
+
const firstParent = await revParseVerify({ cwd }, "HEAD^1");
|
|
41
|
+
if (firstParent === rollback)
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
if (!(await isAncestor({ cwd }, rollback, newHead)))
|
|
45
|
+
return 0; // not ff
|
|
46
|
+
source = await resolveFfSource({ cwd }, current);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
if (!(await revParseVerify({ cwd }, "MERGE_HEAD")))
|
|
50
|
+
return 0;
|
|
51
|
+
source = await resolveFromMergeHead({ cwd }, current);
|
|
52
|
+
}
|
|
53
|
+
const decision = validateMerge(policy, current, source);
|
|
54
|
+
if (decision.ok)
|
|
55
|
+
return 0;
|
|
56
|
+
if (rollback)
|
|
57
|
+
await resetHard({ cwd }, rollback);
|
|
58
|
+
info("");
|
|
59
|
+
printError(`merge-policy: merge blocked by branch policy.`);
|
|
60
|
+
info(` target: ${color.cyan(current)}`);
|
|
61
|
+
info(` source: ${color.cyan(source ?? "(unknown)")}`);
|
|
62
|
+
info(` reason: ${decision.reason}`);
|
|
63
|
+
info(rollback
|
|
64
|
+
? " branch reset to pre-merge state."
|
|
65
|
+
: " run: git merge --abort");
|
|
66
|
+
info(" TAGSMITH_SKIP=1 git merge ... # skip hook (emergency only)");
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
printError(err);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when `branch` matches the glob `pattern`.
|
|
3
|
+
* Glob rules: `*` matches any sequence including `/`; `?` matches a single character.
|
|
4
|
+
* This intentionally differs from POSIX/shell globbing where `*` stops at `/`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function matchSource(pattern: string, branch: string): boolean;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// src/core/merge-policy/match.ts
|
|
2
|
+
/** Convert a branch glob (`*`, `?`) into an anchored RegExp. */
|
|
3
|
+
function globToRegExp(pattern) {
|
|
4
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
5
|
+
const body = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
6
|
+
return new RegExp(`^${body}$`);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* True when `branch` matches the glob `pattern`.
|
|
10
|
+
* Glob rules: `*` matches any sequence including `/`; `?` matches a single character.
|
|
11
|
+
* This intentionally differs from POSIX/shell globbing where `*` stops at `/`.
|
|
12
|
+
*/
|
|
13
|
+
export function matchSource(pattern, branch) {
|
|
14
|
+
return globToRegExp(pattern).test(branch);
|
|
15
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { GitOptions } from "../../git/git.js";
|
|
2
|
+
/** Strip remote prefixes and ^/~ suffixes from a ref name. */
|
|
3
|
+
export declare function normalizeBranch(name: string): string;
|
|
4
|
+
/** Resolve the source branch of an in-progress merge (MERGE_HEAD present). */
|
|
5
|
+
export declare function resolveFromMergeHead(opts: GitOptions, current: string): Promise<string | null>;
|
|
6
|
+
/** Resolve the source branch of a fast-forward merge (post-merge). */
|
|
7
|
+
export declare function resolveFfSource(opts: GitOptions, current: string): Promise<string | null>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { branchesPointingAt, mergeMsg, nameRev, revParse, revParseVerify, } from "../../git/git.js";
|
|
2
|
+
/** Strip remote prefixes and ^/~ suffixes from a ref name. */
|
|
3
|
+
export function normalizeBranch(name) {
|
|
4
|
+
return name
|
|
5
|
+
.replace(/^remotes\/origin\//, "")
|
|
6
|
+
.replace(/^origin\//, "")
|
|
7
|
+
.replace(/[\^~].*$/, "");
|
|
8
|
+
}
|
|
9
|
+
function parseMergeMsg(msg) {
|
|
10
|
+
for (const line of msg.split("\n")) {
|
|
11
|
+
const m = line.match(/^Merge (?:remote-tracking )?branch '([^']+)'/);
|
|
12
|
+
if (m)
|
|
13
|
+
return normalizeBranch(m[1]);
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
/** Resolve the source branch of an in-progress merge (MERGE_HEAD present). */
|
|
18
|
+
export async function resolveFromMergeHead(opts, current) {
|
|
19
|
+
const msg = await mergeMsg(opts);
|
|
20
|
+
if (msg) {
|
|
21
|
+
const parsed = parseMergeMsg(msg);
|
|
22
|
+
if (parsed)
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
const tip = await revParseVerify(opts, "MERGE_HEAD");
|
|
26
|
+
if (tip) {
|
|
27
|
+
const named = (await branchesPointingAt(opts, tip))
|
|
28
|
+
.map(normalizeBranch)
|
|
29
|
+
.filter((n) => n !== current);
|
|
30
|
+
if (named.length > 0)
|
|
31
|
+
return named.sort()[0];
|
|
32
|
+
const nr = await nameRev(opts, tip);
|
|
33
|
+
if (nr)
|
|
34
|
+
return normalizeBranch(nr);
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** Resolve the source branch of a fast-forward merge (post-merge). */
|
|
39
|
+
export async function resolveFfSource(opts, current) {
|
|
40
|
+
const newHead = await revParse(opts, "HEAD");
|
|
41
|
+
const raw = await branchesPointingAt(opts, newHead);
|
|
42
|
+
const candidates = raw.map(normalizeBranch).filter((n) => n !== current);
|
|
43
|
+
// Prefer well-known integration branches, else first alphabetical.
|
|
44
|
+
for (const preferred of ["main", "develop", "testing"]) {
|
|
45
|
+
if (candidates.includes(preferred))
|
|
46
|
+
return preferred;
|
|
47
|
+
}
|
|
48
|
+
if (candidates.length > 0)
|
|
49
|
+
return candidates.sort()[0];
|
|
50
|
+
// No other branch points here. If a remote-tracking ref of the current branch
|
|
51
|
+
// does (e.g. origin/main when HEAD == main), this fast-forward is a pull/sync
|
|
52
|
+
// of the branch into itself — report it as such so the policy allows it.
|
|
53
|
+
// (A genuinely unresolvable source leaves only the local branch ref → null.)
|
|
54
|
+
const selfRemote = raw.some((r) => r !== current && normalizeBranch(r) === current);
|
|
55
|
+
return selfRemote ? current : null;
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare class MergePolicyError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export interface BranchRule {
|
|
4
|
+
allow?: string[];
|
|
5
|
+
deny?: string[];
|
|
6
|
+
}
|
|
7
|
+
export interface MergePolicy {
|
|
8
|
+
protectedBranches: Record<string, BranchRule>;
|
|
9
|
+
onUnknownSource: "block" | "allow";
|
|
10
|
+
}
|
|
11
|
+
/** Extract & validate the optional `mergePolicy` key from a raw config object. */
|
|
12
|
+
export declare function parseMergePolicy(raw: unknown): MergePolicy | null;
|
|
13
|
+
/** Read `.tagsmith.json` from cwd and return its mergePolicy, or null. */
|
|
14
|
+
export declare function loadMergePolicy(cwd: string): Promise<MergePolicy | null>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/core/merge-policy/schema.ts
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { CONFIG_FILENAME } from "../config.js";
|
|
6
|
+
export class MergePolicyError extends Error {
|
|
7
|
+
}
|
|
8
|
+
const branchRuleSchema = z
|
|
9
|
+
.object({
|
|
10
|
+
allow: z.array(z.string().min(1)).optional(),
|
|
11
|
+
deny: z.array(z.string().min(1)).optional(),
|
|
12
|
+
})
|
|
13
|
+
.refine((r) => (r.allow === undefined) !== (r.deny === undefined), {
|
|
14
|
+
message: "set exactly one of allow / deny",
|
|
15
|
+
});
|
|
16
|
+
const mergePolicySchema = z.object({
|
|
17
|
+
protectedBranches: z.record(z.string().min(1), branchRuleSchema),
|
|
18
|
+
onUnknownSource: z.enum(["block", "allow"]).default("block"),
|
|
19
|
+
});
|
|
20
|
+
/** Extract & validate the optional `mergePolicy` key from a raw config object. */
|
|
21
|
+
export function parseMergePolicy(raw) {
|
|
22
|
+
if (typeof raw !== "object" || raw === null)
|
|
23
|
+
return null;
|
|
24
|
+
const block = raw["mergePolicy"];
|
|
25
|
+
if (block === undefined)
|
|
26
|
+
return null;
|
|
27
|
+
const result = mergePolicySchema.safeParse(block);
|
|
28
|
+
if (!result.success) {
|
|
29
|
+
const issues = result.error.issues
|
|
30
|
+
.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
31
|
+
.join("\n");
|
|
32
|
+
throw new MergePolicyError(`Invalid mergePolicy:\n${issues}`);
|
|
33
|
+
}
|
|
34
|
+
return result.data;
|
|
35
|
+
}
|
|
36
|
+
/** Read `.tagsmith.json` from cwd and return its mergePolicy, or null. */
|
|
37
|
+
export async function loadMergePolicy(cwd) {
|
|
38
|
+
let text;
|
|
39
|
+
try {
|
|
40
|
+
text = await readFile(path.join(cwd, CONFIG_FILENAME), "utf8");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
let json;
|
|
46
|
+
try {
|
|
47
|
+
json = JSON.parse(text);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
throw new MergePolicyError(`${CONFIG_FILENAME} is not valid JSON: ${err.message}`);
|
|
51
|
+
}
|
|
52
|
+
return parseMergePolicy(json);
|
|
53
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { MergePolicy } from "./schema.js";
|
|
2
|
+
export type Decision = {
|
|
3
|
+
ok: true;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
reason: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Decide whether merging `source` into `current` is permitted.
|
|
10
|
+
* `source === null` means the source branch could not be resolved.
|
|
11
|
+
*/
|
|
12
|
+
export declare function validateMerge(policy: MergePolicy, current: string, source: string | null): Decision;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { matchSource } from "./match.js";
|
|
2
|
+
/**
|
|
3
|
+
* Decide whether merging `source` into `current` is permitted.
|
|
4
|
+
* `source === null` means the source branch could not be resolved.
|
|
5
|
+
*/
|
|
6
|
+
export function validateMerge(policy, current, source) {
|
|
7
|
+
const rule = policy.protectedBranches[current];
|
|
8
|
+
if (!rule)
|
|
9
|
+
return { ok: true };
|
|
10
|
+
// A branch fast-forwarding/pulling its own remote-tracking ref reports itself
|
|
11
|
+
// as the source. Merging a branch into itself is a sync, never a cross-branch
|
|
12
|
+
// merge, so it is always permitted.
|
|
13
|
+
if (source === current)
|
|
14
|
+
return { ok: true };
|
|
15
|
+
if (source === null) {
|
|
16
|
+
return policy.onUnknownSource === "allow"
|
|
17
|
+
? { ok: true }
|
|
18
|
+
: { ok: false, reason: "could not resolve merge source" };
|
|
19
|
+
}
|
|
20
|
+
if (rule.allow) {
|
|
21
|
+
const ok = rule.allow.some((p) => matchSource(p, source));
|
|
22
|
+
return ok
|
|
23
|
+
? { ok: true }
|
|
24
|
+
: { ok: false, reason: `${current} may only merge: ${rule.allow.join(", ")}` };
|
|
25
|
+
}
|
|
26
|
+
// rule.deny is guaranteed present when rule.allow is absent: the schema
|
|
27
|
+
// refine enforces exactly one of allow / deny. `?? []` keeps this safe even
|
|
28
|
+
// if that invariant is ever weakened.
|
|
29
|
+
const denied = (rule.deny ?? []).some((p) => matchSource(p, source));
|
|
30
|
+
return denied
|
|
31
|
+
? { ok: false, reason: `${current} must not merge ${source}` }
|
|
32
|
+
: { ok: true };
|
|
33
|
+
}
|
package/dist/git/git.d.ts
CHANGED
|
@@ -21,3 +21,21 @@ export interface PushTagOptions extends GitOptions {
|
|
|
21
21
|
remote?: string;
|
|
22
22
|
}
|
|
23
23
|
export declare function pushTag(opts: PushTagOptions): Promise<void>;
|
|
24
|
+
/** Current branch name, or "" when in detached HEAD. */
|
|
25
|
+
export declare function currentBranch(opts: GitOptions): Promise<string>;
|
|
26
|
+
/** Resolve a ref to a full SHA, or null when it does not exist. */
|
|
27
|
+
export declare function revParseVerify(opts: GitOptions, ref: string): Promise<string | null>;
|
|
28
|
+
/** Resolve a ref to a full SHA (throws via GitError when invalid). */
|
|
29
|
+
export declare function revParse(opts: GitOptions, ref: string): Promise<string>;
|
|
30
|
+
/** Read the MERGE_MSG file contents, or null when it is absent. */
|
|
31
|
+
export declare function mergeMsg(opts: GitOptions): Promise<string | null>;
|
|
32
|
+
/** Branch short-names (local + remote) that point at `ref`. */
|
|
33
|
+
export declare function branchesPointingAt(opts: GitOptions, ref: string): Promise<string[]>;
|
|
34
|
+
/** `git name-rev` short name for `ref`, or null when undefined. */
|
|
35
|
+
export declare function nameRev(opts: GitOptions, ref: string): Promise<string | null>;
|
|
36
|
+
/** True when `ancestor` is an ancestor of `descendant`. */
|
|
37
|
+
export declare function isAncestor(opts: GitOptions, ancestor: string, descendant: string): Promise<boolean>;
|
|
38
|
+
/** Number of parents of `ref` (2 = normal merge, >2 = octopus). */
|
|
39
|
+
export declare function parentCount(opts: GitOptions, ref: string): Promise<number>;
|
|
40
|
+
/** Hard-reset the working tree to `ref`. */
|
|
41
|
+
export declare function resetHard(opts: GitOptions, ref: string): Promise<void>;
|
package/dist/git/git.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
import { promisify } from "node:util";
|
|
3
5
|
const execFileAsync = promisify(execFile);
|
|
4
6
|
export class GitError extends Error {
|
|
@@ -45,3 +47,76 @@ export async function createTag(opts) {
|
|
|
45
47
|
export async function pushTag(opts) {
|
|
46
48
|
await git(["push", opts.remote ?? "origin", opts.name], opts.cwd);
|
|
47
49
|
}
|
|
50
|
+
// --- merge-policy helpers ---
|
|
51
|
+
/** Run git, returning { code, stdout } without throwing on non-zero exit. */
|
|
52
|
+
async function tryGit(args, cwd) {
|
|
53
|
+
try {
|
|
54
|
+
const { stdout } = await execFileAsync("git", args, { cwd });
|
|
55
|
+
return { code: 0, stdout };
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const e = err;
|
|
59
|
+
return { code: typeof e.code === "number" ? e.code : 1, stdout: e.stdout ?? "" };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Current branch name, or "" when in detached HEAD. */
|
|
63
|
+
export async function currentBranch(opts) {
|
|
64
|
+
const { stdout } = await tryGit(["branch", "--show-current"], opts.cwd);
|
|
65
|
+
return stdout.trim();
|
|
66
|
+
}
|
|
67
|
+
/** Resolve a ref to a full SHA, or null when it does not exist. */
|
|
68
|
+
export async function revParseVerify(opts, ref) {
|
|
69
|
+
const { code, stdout } = await tryGit(["rev-parse", "-q", "--verify", ref], opts.cwd);
|
|
70
|
+
return code === 0 ? stdout.trim() : null;
|
|
71
|
+
}
|
|
72
|
+
/** Resolve a ref to a full SHA (throws via GitError when invalid). */
|
|
73
|
+
export async function revParse(opts, ref) {
|
|
74
|
+
return (await git(["rev-parse", ref], opts.cwd)).trim();
|
|
75
|
+
}
|
|
76
|
+
/** Read the MERGE_MSG file contents, or null when it is absent. */
|
|
77
|
+
export async function mergeMsg(opts) {
|
|
78
|
+
const { code, stdout } = await tryGit(["rev-parse", "--git-path", "MERGE_MSG"], opts.cwd);
|
|
79
|
+
if (code !== 0)
|
|
80
|
+
return null;
|
|
81
|
+
// `--git-path` returns an absolute path under separate-git-dir / $GIT_DIR
|
|
82
|
+
// setups; resolve relative paths against cwd but keep absolute ones as-is.
|
|
83
|
+
const raw = stdout.trim();
|
|
84
|
+
const file = path.isAbsolute(raw) ? raw : path.join(opts.cwd, raw);
|
|
85
|
+
try {
|
|
86
|
+
return await readFile(file, "utf8");
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Branch short-names (local + remote) that point at `ref`. */
|
|
93
|
+
export async function branchesPointingAt(opts, ref) {
|
|
94
|
+
const { stdout } = await tryGit(["branch", "-a", "--points-at", ref, "--format=%(refname:short)"], opts.cwd);
|
|
95
|
+
return stdout
|
|
96
|
+
.split("\n")
|
|
97
|
+
.map((l) => l.trim())
|
|
98
|
+
.filter((l) => l.length > 0);
|
|
99
|
+
}
|
|
100
|
+
/** `git name-rev` short name for `ref`, or null when undefined. */
|
|
101
|
+
export async function nameRev(opts, ref) {
|
|
102
|
+
const { code, stdout } = await tryGit(["name-rev", "--name-only", "--exclude=tags/*", ref], opts.cwd);
|
|
103
|
+
const name = stdout.trim();
|
|
104
|
+
if (code !== 0 || name === "" || name === "undefined")
|
|
105
|
+
return null;
|
|
106
|
+
return name;
|
|
107
|
+
}
|
|
108
|
+
/** True when `ancestor` is an ancestor of `descendant`. */
|
|
109
|
+
export async function isAncestor(opts, ancestor, descendant) {
|
|
110
|
+
const { code } = await tryGit(["merge-base", "--is-ancestor", ancestor, descendant], opts.cwd);
|
|
111
|
+
return code === 0;
|
|
112
|
+
}
|
|
113
|
+
/** Number of parents of `ref` (2 = normal merge, >2 = octopus). */
|
|
114
|
+
export async function parentCount(opts, ref) {
|
|
115
|
+
const { stdout } = await tryGit(["rev-list", "--parents", "-1", ref], opts.cwd);
|
|
116
|
+
const words = stdout.trim().split(/\s+/).filter((w) => w.length > 0);
|
|
117
|
+
return Math.max(0, words.length - 1);
|
|
118
|
+
}
|
|
119
|
+
/** Hard-reset the working tree to `ref`. */
|
|
120
|
+
export async function resetHard(opts, ref) {
|
|
121
|
+
await git(["reset", "--hard", ref], opts.cwd);
|
|
122
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carllee1983/tagsmith",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Define git tag specs, view tags, and generate the next git tag safely — avoiding ordering or format anomalies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": { "access": "public" },
|
package/schema.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
-
"$id": "https://github.com/
|
|
3
|
+
"$id": "https://github.com/CarlLee1983/Tagsmith/schema.json",
|
|
4
4
|
"title": "Tagsmith config",
|
|
5
5
|
"description": "Tag specification for the tagsmith CLI (.tagsmith.json).",
|
|
6
6
|
"type": "object",
|
|
@@ -54,6 +54,41 @@
|
|
|
54
54
|
"push": {
|
|
55
55
|
"type": "boolean",
|
|
56
56
|
"description": "Default push behaviour for `tagsmith create`."
|
|
57
|
+
},
|
|
58
|
+
"mergePolicy": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"description": "Optional branch merge guardrails enforced by `tagsmith merge-check` via git hooks.",
|
|
61
|
+
"additionalProperties": false,
|
|
62
|
+
"required": ["protectedBranches"],
|
|
63
|
+
"properties": {
|
|
64
|
+
"protectedBranches": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"description": "Map of protected branch name -> merge rule. Only the current branch is checked; others pass.",
|
|
67
|
+
"additionalProperties": {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"description": "Set exactly one of allow (whitelist) or deny (blacklist). Sources support glob (* and ?).",
|
|
70
|
+
"additionalProperties": false,
|
|
71
|
+
"oneOf": [{ "required": ["allow"] }, { "required": ["deny"] }],
|
|
72
|
+
"properties": {
|
|
73
|
+
"allow": {
|
|
74
|
+
"type": "array",
|
|
75
|
+
"description": "Only these sources may merge in; all others are blocked.",
|
|
76
|
+
"items": { "type": "string", "minLength": 1 }
|
|
77
|
+
},
|
|
78
|
+
"deny": {
|
|
79
|
+
"type": "array",
|
|
80
|
+
"description": "These sources are blocked; all others pass.",
|
|
81
|
+
"items": { "type": "string", "minLength": 1 }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"onUnknownSource": {
|
|
87
|
+
"enum": ["block", "allow"],
|
|
88
|
+
"default": "block",
|
|
89
|
+
"description": "Behaviour when the merge source branch cannot be resolved."
|
|
90
|
+
}
|
|
91
|
+
}
|
|
57
92
|
}
|
|
58
93
|
}
|
|
59
94
|
}
|