251 lines
8.0 KiB
TypeScript
251 lines
8.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
applyPatchToSnapshot,
|
|
buildConventionalCommit,
|
|
compareSemVer,
|
|
createSnapshotZip,
|
|
createUnifiedPatch,
|
|
evaluateGitAttributes,
|
|
explainGitignore,
|
|
explainGitignoreWorkspace,
|
|
lintConventionalCommit,
|
|
normalizeChangelog,
|
|
parseSemVer,
|
|
parseUnifiedPatch,
|
|
planRelease,
|
|
satisfiesSemVer,
|
|
} from "../../src/git/tools";
|
|
|
|
describe("Git interchange tools", () => {
|
|
it("parses patch files, renames, hunks, and statistics", () => {
|
|
const source = `diff --git a/old.txt b/new.txt\nsimilarity index 90%\nrename from old.txt\nrename to new.txt\n--- a/old.txt\n+++ b/new.txt\n@@ -1,2 +1,2 @@ section\n same\n-old\n+new\n`;
|
|
const parsed = parseUnifiedPatch(source);
|
|
expect(parsed).toMatchObject({ added: 1, deleted: 1 });
|
|
expect(parsed.files[0]).toMatchObject({
|
|
status: "renamed",
|
|
oldPath: "old.txt",
|
|
newPath: "new.txt",
|
|
similarity: 90,
|
|
});
|
|
expect(parsed.files[0]?.hunks[0]?.validCounts).toBe(true);
|
|
});
|
|
|
|
it("decodes Git C-quoted octal paths", () => {
|
|
const source = `diff --git "a/foo\\040bar.txt" "b/foo\\040bar.txt"\n--- "a/foo\\040bar.txt"\n+++ "b/foo\\040bar.txt"\n@@ -1 +1 @@\n-old\n+new\n`;
|
|
expect(parseUnifiedPatch(source).files[0]).toMatchObject({
|
|
oldPath: "foo bar.txt",
|
|
newPath: "foo bar.txt",
|
|
});
|
|
});
|
|
|
|
it("authors a patch that parses with valid counts", () => {
|
|
const output = createUnifiedPatch(
|
|
"a\nb\nc\n",
|
|
"a\nB\nc\nd\n",
|
|
"src/a file.txt",
|
|
1,
|
|
);
|
|
expect(output).toContain("-b");
|
|
expect(output).toContain("+B");
|
|
const parsed = parseUnifiedPatch(output);
|
|
expect(parsed.diagnostics).toEqual([]);
|
|
expect(parsed).toMatchObject({ added: 2, deleted: 1 });
|
|
});
|
|
|
|
it("applies and reverses exact text patches atomically", () => {
|
|
const before = "a\nb\nc\n";
|
|
const after = "a\nB\nc\nd\n";
|
|
const patch = createUnifiedPatch(before, after, "src/file.txt", 1);
|
|
const applied = applyPatchToSnapshot(patch, {
|
|
"src/file.txt": before,
|
|
"README.md": "keep\n",
|
|
});
|
|
expect(applied.files).toEqual({
|
|
"src/file.txt": after,
|
|
"README.md": "keep\n",
|
|
});
|
|
expect(
|
|
applyPatchToSnapshot(patch, applied.files, true).files["src/file.txt"],
|
|
).toBe(before);
|
|
expect(() =>
|
|
applyPatchToSnapshot(patch, { "src/file.txt": "different\n" }),
|
|
).toThrow(/exact hunk match/u);
|
|
});
|
|
|
|
it("builds a deterministic text snapshot ZIP", () => {
|
|
const one = createSnapshotZip({ "b.txt": "B\n", "a.txt": "A\n" });
|
|
const two = createSnapshotZip({ "a.txt": "A\n", "b.txt": "B\n" });
|
|
expect([...one.slice(0, 4)]).toEqual([0x50, 0x4b, 0x03, 0x04]);
|
|
expect(one).toEqual(two);
|
|
});
|
|
|
|
it("explains ordered ignore rules and blocked parents", () => {
|
|
const source = `dist/\n*.log\n!keep.log\ncache/\n!cache/readme.md\n`;
|
|
const results = explainGitignore(source, [
|
|
"dist/app.js",
|
|
"debug.log",
|
|
"keep.log",
|
|
"cache/readme.md",
|
|
]);
|
|
expect(results.map((item) => item.ignored)).toEqual([
|
|
true,
|
|
true,
|
|
false,
|
|
true,
|
|
]);
|
|
expect(results[3]?.explanation).toMatch(/parent cache/u);
|
|
});
|
|
|
|
it("propagates ordinary directory matches but respects directory-only inputs", () => {
|
|
const results = explainGitignore(`dist/\nbuild\n`, [
|
|
"dist",
|
|
"dist/",
|
|
"dist/app.js",
|
|
"build",
|
|
"build/",
|
|
"build/app.js",
|
|
]);
|
|
expect(results.map((item) => item.ignored)).toEqual([
|
|
false,
|
|
true,
|
|
true,
|
|
true,
|
|
true,
|
|
true,
|
|
]);
|
|
});
|
|
|
|
it("evaluates global, info, nested, and tracked precedence with winning source", () => {
|
|
const results = explainGitignoreWorkspace(
|
|
{
|
|
globalExclude: "*.tmp\n*.log\n",
|
|
infoExclude: "private/\n",
|
|
root: "!keep.log\nsrc/*.tmp\n",
|
|
nested: {
|
|
"src/.gitignore": "!generated.tmp\n*.cache\n",
|
|
"src/deep/.gitignore": "!keep.cache\n",
|
|
},
|
|
tracked: ["private/tracked.txt"],
|
|
},
|
|
[
|
|
"debug.log",
|
|
"keep.log",
|
|
"src/generated.tmp",
|
|
"src/other.tmp",
|
|
"src/deep/drop.cache",
|
|
"src/deep/keep.cache",
|
|
"private/tracked.txt",
|
|
],
|
|
);
|
|
expect(results.map((item) => item.ignored)).toEqual([
|
|
true,
|
|
false,
|
|
false,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
]);
|
|
expect(results[2]?.explanation).toContain("src/.gitignore:1");
|
|
expect(results[5]?.explanation).toContain("src/deep/.gitignore:1");
|
|
expect(results[6]).toMatchObject({ tracked: true });
|
|
});
|
|
|
|
it("evaluates .gitattributes states with per-attribute last matches", () => {
|
|
const [script, secret, image] = evaluateGitAttributes(
|
|
`* text=auto\n*.sh text eol=lf\nsecret.sh -text !eol\n*.png -text binary\n`,
|
|
["scripts/build.sh", "secret.sh", "logo.png"],
|
|
);
|
|
expect(script?.attributes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: "eol",
|
|
state: { kind: "value", value: "lf" },
|
|
}),
|
|
expect.objectContaining({ name: "text", state: { kind: "set" } }),
|
|
]),
|
|
);
|
|
expect(image?.attributes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ name: "binary", state: { kind: "set" } }),
|
|
expect.objectContaining({ name: "text", state: { kind: "unset" } }),
|
|
]),
|
|
);
|
|
expect(secret?.attributes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: "eol",
|
|
state: { kind: "unspecified" },
|
|
}),
|
|
]),
|
|
);
|
|
expect(
|
|
evaluateGitAttributes("docs/ text\n", ["docs/readme.md"])[0]
|
|
?.diagnostics[0],
|
|
).toContain("trailing slash");
|
|
});
|
|
|
|
it("implements SemVer precedence and basic ranges", () => {
|
|
expect(compareSemVer("1.0.0-alpha.1", "1.0.0-alpha.beta")).toBeLessThan(0);
|
|
expect(compareSemVer("1.0.0+one", "1.0.0+two")).toBe(0);
|
|
expect(satisfiesSemVer("1.4.2", "^1.2.0 <2.0.0")).toBe(true);
|
|
expect(satisfiesSemVer("2.0.0", "^1.2.0 || >=3.0.0")).toBe(false);
|
|
expect(satisfiesSemVer("1.3.0-beta.1", "^1.2.0")).toBe(false);
|
|
expect(satisfiesSemVer("1.9.9", "~1")).toBe(true);
|
|
expect(satisfiesSemVer("0.8.0", "^0")).toBe(true);
|
|
expect(satisfiesSemVer("0.0.9", "^0.0")).toBe(true);
|
|
expect(satisfiesSemVer("2.3.9", "1.2 - 2.3")).toBe(true);
|
|
expect(satisfiesSemVer("1.99.0", "<=1")).toBe(true);
|
|
expect(satisfiesSemVer("2.0.0", "<=1")).toBe(false);
|
|
expect(() => parseSemVer("9007199254740992.0.0")).toThrow(/safe integer/u);
|
|
});
|
|
|
|
it("builds and lints conventional commits", () => {
|
|
const message = buildConventionalCommit({
|
|
type: "feat",
|
|
scope: "api",
|
|
breaking: true,
|
|
subject: "change response shape",
|
|
breakingDescription: "Clients must read data.items.",
|
|
issues: "#42",
|
|
});
|
|
expect(message).toContain("feat(api)!");
|
|
expect(message).toContain("BREAKING CHANGE:");
|
|
expect(lintConventionalCommit(message).valid).toBe(true);
|
|
expect(lintConventionalCommit("bad header").valid).toBe(false);
|
|
});
|
|
|
|
it("normalizes changelog headings, order, and duplicate bullets", () => {
|
|
const result = normalizeChangelog(
|
|
`# Changelog\n\n## 1.0.0 - 2026-01-01\n\n### fixed\n\n- B\n- B\n\n### Added\n\n- A\n`,
|
|
);
|
|
expect(result.deduplicated).toBe(1);
|
|
expect(result.output.indexOf("### Added")).toBeLessThan(
|
|
result.output.indexOf("### Fixed"),
|
|
);
|
|
expect(result.output).toContain("## [1.0.0] - 2026-01-01");
|
|
});
|
|
|
|
it("plans a release from valid conventional commit evidence", () => {
|
|
const plan = planRelease({
|
|
currentVersion: "1.4.2",
|
|
date: "2026-09-01",
|
|
commits: [
|
|
"fix(api): retain response headers",
|
|
"feat(ui): add timeline",
|
|
"bad commit",
|
|
],
|
|
});
|
|
expect(plan).toMatchObject({
|
|
bump: "minor",
|
|
nextVersion: "1.5.0",
|
|
tag: "v1.5.0",
|
|
validCommits: 2,
|
|
});
|
|
expect(plan.releaseNotes).toContain("### Added");
|
|
expect(plan.releaseNotes).toContain("### Fixed");
|
|
expect(plan.ignoredCommits).toHaveLength(1);
|
|
expect(plan.commands.at(-1)).toContain("v1.5.0");
|
|
});
|
|
});
|