@carllee1983/tagsmith 0.2.1 → 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 CHANGED
@@ -11,6 +11,7 @@
11
11
  - 🛡️ **防呆** — 建立前驗證格式、版本可解析、嚴格遞增、tag 不重複
12
12
  - 🚀 **零設定** — 無設定檔時自動以 semver 推斷 pattern,讀 repo 既有 tag 即可用
13
13
  - 🧩 **可擴充** — 版本模型走介面抽象,新增不動核心邏輯
14
+ - 🚧 **合併護欄** — 以 `mergePolicy` 限制受保護分支的合併來源(白 / 黑名單),由 git hook 自動把關
14
15
 
15
16
  ## 安裝
16
17
 
@@ -325,6 +326,81 @@ tagsmith list --all
325
326
  可用 git `pre-push` hook 在推送時自動驗證 tag,擋下不符規格者。
326
327
  詳見 [docs/husky-pre-push.md](docs/husky-pre-push.md)(需安裝 `@carllee1983/tagsmith`)。
327
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 呼叫,套用政策;非日常手動輸入 |
403
+
328
404
  ## 結束代碼
329
405
 
330
406
  | 代碼 | 意義 |
@@ -0,0 +1,5 @@
1
+ export interface HooksInstallFlags {
2
+ force?: boolean;
3
+ }
4
+ export declare function runHooksInstall(cwd: string, flags: HooksInstallFlags): Promise<number>;
5
+ export declare function runHooksUninstall(cwd: string): Promise<number>;
@@ -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
@@ -7,6 +7,8 @@ import { runNext } from "./next.js";
7
7
  import { runCreate } from "./create.js";
8
8
  import { runGuide } from "./guide.js";
9
9
  import { runCheck } from "./check.js";
10
+ import { runMergeCheck } from "./merge-check.js";
11
+ import { runHooksInstall, runHooksUninstall } from "./hooks.js";
10
12
  import { printError } from "./ui.js";
11
13
  const require = createRequire(import.meta.url);
12
14
  const { version } = require("../../package.json");
@@ -93,6 +95,29 @@ Examples:
93
95
  $ tagsmith create --dry-run Preview without creating
94
96
  $ tagsmith create --tag release Create the next tag on a named tag line
95
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
+ });
96
121
  program.parseAsync(process.argv).catch((err) => {
97
122
  printError(err);
98
123
  process.exitCode = 1;
@@ -0,0 +1,4 @@
1
+ export interface MergeCheckFlags {
2
+ mode?: "merge-head" | "post-merge";
3
+ }
4
+ export declare function runMergeCheck(cwd: string, flags: MergeCheckFlags): Promise<number>;
@@ -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.2.1",
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
@@ -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
  }