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"); }); });