mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-09 20:51:22 +00:00
feat(ci): add skill release commit validation
Add CI check to ensure commits touching skills/<name>/** use Conventional Commit subjects. Also validates SKILL.md version alignment during publish/sync.
This commit is contained in:
@@ -11,6 +11,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -18,6 +20,9 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Verify skill release commits
|
||||
run: npm run verify:skill-release-commits
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ bash scripts/sync-clawhub.sh <skill> # sync one skill
|
||||
|
||||
Release hooks are configured via `.releaserc.yml`. This repo does not stage a separate release directory: publish reads the skill directory directly and validates that local package references and CLI bin targets are self-contained.
|
||||
|
||||
Every skill release must keep the `version:` in that skill's `SKILL.md` aligned with the version being published. `publish-skill.mjs` and `sync-clawhub.mjs` both reject mismatches so a registry payload cannot ship with stale skill metadata.
|
||||
|
||||
Commits that touch `skills/<name>/**` must use Conventional Commit subjects, for example `fix(baoyu-post-to-wechat): handle WeChat editor focus`. CI runs `npm run verify:skill-release-commits` against the pushed or PR commit range so bare subjects like `Fix WeChat browser article publishing` cannot bypass per-skill release versioning silently.
|
||||
|
||||
## Shared Workspace Packages
|
||||
|
||||
`packages/` is the source of truth for shared runtime code. Most skills consume shared packages from npm with semver ranges. `baoyu-url-to-markdown` is the exception: it vendors the `baoyu-fetch` runtime into `skills/baoyu-url-to-markdown/scripts/lib/` so the published skill is self-contained and does not depend on the `baoyu-fetch` npm package.
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node ./scripts/run-node-tests.mjs",
|
||||
"test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage"
|
||||
"test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage",
|
||||
"verify:skill-release-commits": "node ./scripts/verify-skill-release-commits.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
|
||||
@@ -25,6 +25,37 @@ const MIME_MAP = {
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
|
||||
export async function readSkillMetadataVersion(root) {
|
||||
const skillFile = await findSkillMarkdown(root);
|
||||
const source = await fs.readFile(skillFile, "utf8");
|
||||
const version = readSkillFrontmatterVersion(source);
|
||||
if (!version) {
|
||||
throw new Error(`Missing version in ${path.relative(process.cwd(), skillFile) || skillFile}`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
export async function validateSkillMetadataVersion(root, expectedVersion) {
|
||||
const actualVersion = await readSkillMetadataVersion(root);
|
||||
if (actualVersion !== expectedVersion) {
|
||||
throw new Error(
|
||||
`SKILL.md version mismatch for ${path.basename(path.resolve(root))}: expected ${expectedVersion}, found ${actualVersion}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function readSkillFrontmatterVersion(source) {
|
||||
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source);
|
||||
if (!match) return null;
|
||||
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const versionMatch = /^version:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/.exec(line.trim());
|
||||
if (versionMatch) return versionMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function listReleaseFiles(root) {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const files = [];
|
||||
@@ -53,6 +84,20 @@ export async function listReleaseFiles(root) {
|
||||
return files;
|
||||
}
|
||||
|
||||
async function findSkillMarkdown(root) {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
for (const name of ["SKILL.md", "skill.md"]) {
|
||||
const candidate = path.join(resolvedRoot, name);
|
||||
try {
|
||||
const stat = await fs.stat(candidate);
|
||||
if (stat.isFile()) return candidate;
|
||||
} catch {
|
||||
// Try the next supported skill filename.
|
||||
}
|
||||
}
|
||||
throw new Error(`Missing SKILL.md in ${resolvedRoot}`);
|
||||
}
|
||||
|
||||
export async function validateSelfContainedRelease(root) {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const files = await listReleaseFiles(root);
|
||||
|
||||
@@ -6,6 +6,9 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
listReleaseFiles,
|
||||
readSkillFrontmatterVersion,
|
||||
readSkillMetadataVersion,
|
||||
validateSkillMetadataVersion,
|
||||
validateSelfContainedRelease,
|
||||
} from "./release-files.mjs";
|
||||
|
||||
@@ -45,6 +48,34 @@ test("listReleaseFiles skips generated paths and returns sorted relative paths",
|
||||
);
|
||||
});
|
||||
|
||||
test("readSkillFrontmatterVersion reads quoted and unquoted versions", () => {
|
||||
assert.equal(readSkillFrontmatterVersion("---\nname: demo\nversion: 1.2.3\n---\n"), "1.2.3");
|
||||
assert.equal(readSkillFrontmatterVersion("---\nversion: \"2.0.0\"\n---\n"), "2.0.0");
|
||||
assert.equal(readSkillFrontmatterVersion("# Missing frontmatter\n"), null);
|
||||
});
|
||||
|
||||
test("validateSkillMetadataVersion accepts matching SKILL.md version", async (t) => {
|
||||
const root = await makeTempDir("baoyu-release-version-ok-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
await writeFile(path.join(root, "SKILL.md"), "---\nname: demo\nversion: 1.2.3\n---\n");
|
||||
|
||||
assert.equal(await readSkillMetadataVersion(root), "1.2.3");
|
||||
await assert.doesNotReject(() => validateSkillMetadataVersion(root, "1.2.3"));
|
||||
});
|
||||
|
||||
test("validateSkillMetadataVersion rejects mismatched SKILL.md version", async (t) => {
|
||||
const root = await makeTempDir("baoyu-release-version-mismatch-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
await writeFile(path.join(root, "SKILL.md"), "---\nname: demo\nversion: 1.2.3\n---\n");
|
||||
|
||||
await assert.rejects(
|
||||
() => validateSkillMetadataVersion(root, "1.2.4"),
|
||||
/SKILL\.md version mismatch/,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateSelfContainedRelease accepts file dependencies that stay within the release root", async (t) => {
|
||||
const root = await makeTempDir("baoyu-release-ok-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
const SKILL_PATH_PATTERN = /^skills\/([^/]+)\//;
|
||||
const CONVENTIONAL_SUBJECT_PATTERN =
|
||||
/^(?<type>[a-z][a-z0-9-]*)(?:\((?<scope>[^()\n]+)\))?(?<breaking>!)?: (?<description>\S[\s\S]*)$/;
|
||||
|
||||
export function parseConventionalCommitSubject(subject) {
|
||||
const match = CONVENTIONAL_SUBJECT_PATTERN.exec(subject.trim());
|
||||
if (!match?.groups) return null;
|
||||
|
||||
return {
|
||||
type: match.groups.type,
|
||||
scope: match.groups.scope ?? "",
|
||||
breaking: Boolean(match.groups.breaking),
|
||||
description: match.groups.description,
|
||||
};
|
||||
}
|
||||
|
||||
export function changedSkillsForPaths(paths) {
|
||||
const skills = new Set();
|
||||
for (const filePath of paths) {
|
||||
const normalizedPath = filePath.replaceAll("\\", "/");
|
||||
const match = SKILL_PATH_PATTERN.exec(normalizedPath);
|
||||
if (match) skills.add(match[1]);
|
||||
}
|
||||
return [...skills].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function validateSkillReleaseCommit({ commit = "", subject, paths }) {
|
||||
const skills = changedSkillsForPaths(paths);
|
||||
if (skills.length === 0) return [];
|
||||
|
||||
const parsed = parseConventionalCommitSubject(subject);
|
||||
if (parsed) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
commit,
|
||||
subject,
|
||||
skills,
|
||||
message: `Commit ${formatCommit(commit)} changes ${formatSkills(skills)} but its subject is not a Conventional Commit: ${subject}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function formatSkillReleaseFailures(failures) {
|
||||
if (failures.length === 0) return "";
|
||||
|
||||
const lines = [
|
||||
"Skill release commit check failed.",
|
||||
"",
|
||||
"Commits that touch skills/<name>/** must use Conventional Commit subjects so per-skill release tooling can derive a version bump.",
|
||||
"Example: fix(baoyu-post-to-wechat): handle WeChat editor focus",
|
||||
"",
|
||||
];
|
||||
|
||||
for (const failure of failures) {
|
||||
lines.push(`- ${failure.message}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatCommit(commit) {
|
||||
return commit ? commit.slice(0, 12) : "<unknown>";
|
||||
}
|
||||
|
||||
function formatSkills(skills) {
|
||||
return skills.map((skill) => `skills/${skill}/**`).join(", ");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
changedSkillsForPaths,
|
||||
formatSkillReleaseFailures,
|
||||
parseConventionalCommitSubject,
|
||||
validateSkillReleaseCommit,
|
||||
} from "./skill-release-guard.mjs";
|
||||
|
||||
test("parseConventionalCommitSubject accepts scoped, unscoped, and breaking subjects", () => {
|
||||
assert.deepEqual(parseConventionalCommitSubject("fix(baoyu-post-to-wechat): repair editor paste"), {
|
||||
type: "fix",
|
||||
scope: "baoyu-post-to-wechat",
|
||||
breaking: false,
|
||||
description: "repair editor paste",
|
||||
});
|
||||
assert.deepEqual(parseConventionalCommitSubject("feat!: change skill metadata format"), {
|
||||
type: "feat",
|
||||
scope: "",
|
||||
breaking: true,
|
||||
description: "change skill metadata format",
|
||||
});
|
||||
assert.equal(parseConventionalCommitSubject("Fix WeChat browser article publishing"), null);
|
||||
});
|
||||
|
||||
test("changedSkillsForPaths returns sorted unique skills", () => {
|
||||
assert.deepEqual(
|
||||
changedSkillsForPaths([
|
||||
"README.md",
|
||||
"skills/baoyu-post-to-wechat/scripts/wechat-article.ts",
|
||||
"skills/baoyu-post-to-wechat/SKILL.md",
|
||||
"skills/baoyu-url-to-markdown/SKILL.md",
|
||||
]),
|
||||
["baoyu-post-to-wechat", "baoyu-url-to-markdown"],
|
||||
);
|
||||
});
|
||||
|
||||
test("validateSkillReleaseCommit ignores non-skill changes", () => {
|
||||
assert.deepEqual(
|
||||
validateSkillReleaseCommit({
|
||||
subject: "Fix test workflow",
|
||||
paths: [".github/workflows/test.yml"],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("validateSkillReleaseCommit rejects non-conventional skill commit subjects", () => {
|
||||
const failures = validateSkillReleaseCommit({
|
||||
commit: "81377416b4a7",
|
||||
subject: "Fix WeChat browser article publishing",
|
||||
paths: ["skills/baoyu-post-to-wechat/scripts/wechat-article.ts"],
|
||||
});
|
||||
|
||||
assert.equal(failures.length, 1);
|
||||
assert.match(failures[0]!.message, /Conventional Commit/);
|
||||
assert.match(formatSkillReleaseFailures(failures), /fix\(baoyu-post-to-wechat\):/);
|
||||
});
|
||||
|
||||
test("validateSkillReleaseCommit accepts conventional skill commit subjects", () => {
|
||||
assert.deepEqual(
|
||||
validateSkillReleaseCommit({
|
||||
subject: "fix(browser): ensure tab activation before copy/paste in WeChat editor",
|
||||
paths: ["skills/baoyu-post-to-wechat/scripts/wechat-article.ts"],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -5,7 +5,12 @@ import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { listReleaseFiles, mimeType, validateSelfContainedRelease } from "./lib/release-files.mjs";
|
||||
import {
|
||||
listReleaseFiles,
|
||||
mimeType,
|
||||
validateSelfContainedRelease,
|
||||
validateSkillMetadataVersion,
|
||||
} from "./lib/release-files.mjs";
|
||||
|
||||
const DEFAULT_REGISTRY = "https://clawhub.ai";
|
||||
|
||||
@@ -21,6 +26,7 @@ async function main() {
|
||||
? await fs.readFile(path.resolve(options.changelogFile), "utf8")
|
||||
: "";
|
||||
|
||||
await validateSkillMetadataVersion(skillDir, options.version);
|
||||
await validateSelfContainedRelease(skillDir);
|
||||
const files = await listReleaseFiles(skillDir);
|
||||
if (files.length === 0) {
|
||||
|
||||
+46
-15
@@ -6,7 +6,13 @@ import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
import { listReleaseFiles, mimeType, validateSelfContainedRelease } from "./lib/release-files.mjs";
|
||||
import {
|
||||
listReleaseFiles,
|
||||
mimeType,
|
||||
readSkillMetadataVersion,
|
||||
validateSelfContainedRelease,
|
||||
validateSkillMetadataVersion,
|
||||
} from "./lib/release-files.mjs";
|
||||
|
||||
const DEFAULT_REGISTRY = "https://clawhub.ai";
|
||||
|
||||
@@ -38,9 +44,11 @@ async function main() {
|
||||
|
||||
const locals = await mapWithConcurrency(skills, options.concurrency, async (skill) => {
|
||||
const files = await collectReleaseFiles(skill.folder);
|
||||
const localVersion = await readSkillMetadataVersion(skill.folder);
|
||||
const fingerprint = buildFingerprint(files);
|
||||
return {
|
||||
...skill,
|
||||
localVersion,
|
||||
fileCount: files.length,
|
||||
fingerprint,
|
||||
};
|
||||
@@ -119,12 +127,12 @@ async function main() {
|
||||
for (const candidate of actionable) {
|
||||
const version =
|
||||
candidate.status === "new"
|
||||
? "1.0.0"
|
||||
: bumpSemver(candidate.latestVersion, options.bump);
|
||||
? candidate.localVersion
|
||||
: resolveUpdateVersion(candidate, options.bump);
|
||||
|
||||
console.log(`Publishing ${candidate.slug}@${version}`);
|
||||
try {
|
||||
const files = await collectReleaseFiles(candidate.folder);
|
||||
const files = await collectReleaseFiles(candidate.folder, version);
|
||||
await publishSkill({
|
||||
registry,
|
||||
token: config.token,
|
||||
@@ -325,7 +333,10 @@ async function hasSkillMarker(folder) {
|
||||
);
|
||||
}
|
||||
|
||||
async function collectReleaseFiles(root) {
|
||||
async function collectReleaseFiles(root, expectedVersion = "") {
|
||||
if (expectedVersion) {
|
||||
await validateSkillMetadataVersion(root, expectedVersion);
|
||||
}
|
||||
await validateSelfContainedRelease(root);
|
||||
return listReleaseFiles(root);
|
||||
}
|
||||
@@ -423,28 +434,48 @@ async function mapWithConcurrency(items, limit, fn) {
|
||||
|
||||
function formatCandidate(candidate, bump) {
|
||||
if (candidate.status === "new") {
|
||||
return `${candidate.slug} NEW (${candidate.fileCount} files)`;
|
||||
return `${candidate.slug} NEW ${candidate.localVersion} (${candidate.fileCount} files)`;
|
||||
}
|
||||
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${bumpSemver(
|
||||
candidate.latestVersion,
|
||||
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${resolveUpdateVersion(
|
||||
candidate,
|
||||
bump
|
||||
)} (${candidate.fileCount} files)`;
|
||||
}
|
||||
|
||||
function bumpSemver(version, bump) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
|
||||
if (!match) {
|
||||
throw new Error(`Invalid semver: ${version}`);
|
||||
function resolveUpdateVersion(candidate, bump) {
|
||||
if (compareSemver(candidate.localVersion, candidate.latestVersion) > 0) {
|
||||
return candidate.localVersion;
|
||||
}
|
||||
const major = Number(match[1]);
|
||||
const minor = Number(match[2]);
|
||||
const patch = Number(match[3]);
|
||||
return bumpSemver(candidate.latestVersion, bump);
|
||||
}
|
||||
|
||||
function compareSemver(left, right) {
|
||||
const leftParts = parseSemver(left);
|
||||
const rightParts = parseSemver(right);
|
||||
for (let index = 0; index < leftParts.length; index += 1) {
|
||||
if (leftParts[index] !== rightParts[index]) {
|
||||
return leftParts[index] - rightParts[index];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function bumpSemver(version, bump) {
|
||||
const [major, minor, patch] = parseSemver(version);
|
||||
|
||||
if (bump === "major") return `${major + 1}.0.0`;
|
||||
if (bump === "minor") return `${major}.${minor + 1}.0`;
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
|
||||
function parseSemver(version) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
|
||||
if (!match) {
|
||||
throw new Error(`Invalid semver: ${version}`);
|
||||
}
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
function sanitizeSlug(value) {
|
||||
return value
|
||||
.trim()
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import {
|
||||
formatSkillReleaseFailures,
|
||||
validateSkillReleaseCommit,
|
||||
} from "./lib/skill-release-guard.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const ZERO_SHA = /^0{40}$/;
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const range = await resolveRange(options);
|
||||
const commits = await listCommits(range);
|
||||
|
||||
const failures = [];
|
||||
for (const commit of commits) {
|
||||
const [subject, paths] = await Promise.all([readCommitSubject(commit), readCommitPaths(commit)]);
|
||||
failures.push(...validateSkillReleaseCommit({ commit, subject, paths }));
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(formatSkillReleaseFailures(failures));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Skill release commit check passed (${commits.length} commit${commits.length === 1 ? "" : "s"} checked).`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
base: "",
|
||||
head: "HEAD",
|
||||
range: "",
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--base") {
|
||||
options.base = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--head") {
|
||||
options.head = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--range") {
|
||||
options.range = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "-h" || arg === "--help") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: verify-skill-release-commits.mjs [--base <rev> --head <rev> | --range <rev-range>]
|
||||
|
||||
Checks non-merge commits in the selected range. Any commit that touches
|
||||
skills/<name>/** must use a Conventional Commit subject, for example:
|
||||
fix(baoyu-post-to-wechat): handle WeChat editor focus
|
||||
|
||||
Without explicit arguments, GitHub Actions event metadata is used when
|
||||
available. Otherwise the fallback range is HEAD^..HEAD.`);
|
||||
}
|
||||
|
||||
async function resolveRange(options) {
|
||||
if (options.range) {
|
||||
return { args: [options.range], label: options.range };
|
||||
}
|
||||
|
||||
if (options.base) {
|
||||
return {
|
||||
args: [`${options.base}..${options.head || "HEAD"}`],
|
||||
label: `${options.base}..${options.head || "HEAD"}`,
|
||||
};
|
||||
}
|
||||
|
||||
const githubRange = await resolveGitHubRange();
|
||||
if (githubRange) return githubRange;
|
||||
|
||||
return { args: ["HEAD^..HEAD"], label: "HEAD^..HEAD" };
|
||||
}
|
||||
|
||||
async function resolveGitHubRange() {
|
||||
const eventPath = process.env.GITHUB_EVENT_PATH;
|
||||
if (!eventPath) return null;
|
||||
|
||||
let event = null;
|
||||
try {
|
||||
event = JSON.parse(await fs.readFile(eventPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (event?.pull_request?.base?.sha) {
|
||||
const base = event.pull_request.base.sha;
|
||||
const head = process.env.GITHUB_SHA || "HEAD";
|
||||
return { args: [`${base}..${head}`], label: `${base}..${head}` };
|
||||
}
|
||||
|
||||
if (event?.before && !ZERO_SHA.test(event.before)) {
|
||||
const head = event.after || process.env.GITHUB_SHA || "HEAD";
|
||||
return { args: [`${event.before}..${head}`], label: `${event.before}..${head}` };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listCommits(range) {
|
||||
const output = await git(["rev-list", "--no-merges", "--reverse", ...range.args]);
|
||||
return output ? output.split("\n").filter(Boolean) : [];
|
||||
}
|
||||
|
||||
async function readCommitSubject(commit) {
|
||||
return git(["log", "-1", "--format=%s", commit]);
|
||||
}
|
||||
|
||||
async function readCommitPaths(commit) {
|
||||
const output = await git(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", commit]);
|
||||
return output ? output.split("\n").filter(Boolean) : [];
|
||||
}
|
||||
|
||||
async function git(args) {
|
||||
const { stdout } = await execFileAsync("git", args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return stdout.trimEnd();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user