Skip to content

Commit

Permalink
feat(git): add package (#93)
Browse files Browse the repository at this point in the history
  • Loading branch information
lowlighter authored Dec 17, 2024
1 parent f0412dd commit 1887265
Show file tree
Hide file tree
Showing 12 changed files with 371 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .github/deno_readme.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
</ul>
</li>
</ul>
<ul data-for="git">
<li>Perform <code>git</code> operation and manipulate outputs</li>
<li>Helper to generate changelog and auto-bump your version file</li>
</ul>
<ul data-for="logger">
<li>
Simple logger library with configurable log level and tags
Expand Down
6 changes: 6 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 68 additions & 0 deletions git/blame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Imports
import type { Nullable } from "@libs/typing"
import { command } from "@libs/run/command"
export type { Nullable }

/**
* Parse the output of `git blame --line-porcelain`.
*/
export function blame(path: string, { stdout = "" } = {} as BlameOptions): BlameEntry[] {
if (!stdout) {
;({ stdout } = command("git", ["blame", "--line-porcelain", path], { sync: true, throw: true }))
}
const entries = []
const porcelain = stdout.trim().split("\n")
while (porcelain.length) {
entries.push({
sha: porcelain.shift()!.substring(0, 40),
author: {
name: porcelain.shift()!.substring("author ".length),
mail: porcelain.shift()!.substring("author-mail ".length),
time: Number(porcelain.shift()!.substring("author-time ".length)),
tz: Number(porcelain.shift()!.substring("author-tz ".length)),
},
committer: {
name: porcelain.shift()!.substring("committer ".length),
mail: porcelain.shift()!.substring("committer-mail ".length),
time: Number(porcelain.shift()!.substring("committer-time ".length)),
tz: Number(porcelain.shift()!.substring("committer-tz ".length)),
},
summary: porcelain.shift()!.substring("summary ".length),
previous: porcelain[0].startsWith("previous ") ? porcelain.shift()!.substring("previous ".length) : null,
filename: porcelain.shift()!.substring("filename ".length),
content: porcelain.shift()!.substring(1),
})
}
return entries
}

/** Options for {@linkcode blame()}. */
export type BlameOptions = {
/**
* The output returned by `git blame --line-porcelain`.
*
* If empty, the function will run `git blame --line-porcelain` synchronously to populate this field.
*/
stdout?: string
}

/** Git blame entry. */
export type BlameEntry = {
sha: string
author: {
name: string
mail: string
time: number
tz: number
}
committer: {
name: string
mail: string
time: number
tz: number
}
summary: string
previous: Nullable<string>
filename: string
content: string
}
47 changes: 47 additions & 0 deletions git/blame_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { blame, type BlameEntry } from "./blame.ts"
import { expect, test } from "@libs/testing"

const expected = [
{
sha: "a".repeat(40),
author: { name: "foo", mail: "<[email protected]>", time: 1, tz: 1 },
committer: { name: "foo", mail: "<[email protected]>", time: 1, tz: 1 },
summary: "feat: initial commit",
previous: null,
filename: "foo.ts",
content: "console.log('Hello, World!')",
},
{
sha: "b".repeat(40),
author: { name: "bar", mail: "<[email protected]>", time: 2, tz: -1 },
committer: { name: "bar", mail: "<[email protected]>", time: 2, tz: -1 },
summary: "fix: add a comment",
previous: "a".repeat(40),
filename: "foo.ts",
content: "// Say hello !",
},
] as BlameEntry[]

function format(entries: BlameEntry[]) {
return entries.map((entry) =>
[
`${entry.sha} 1 1 1`,
`author ${entry.author.name}`,
`author-mail ${entry.author.mail}`,
`author-time ${entry.author.time}`,
`author-tz ${entry.author.tz}`,
`committer ${entry.committer.name}`,
`committer-mail ${entry.committer.mail}`,
`committer-time ${entry.committer.time}`,
`committer-tz ${entry.committer.tz}`,
`summary ${entry.summary}`,
entry.previous ? `previous ${entry.previous}` : "",
`filename ${entry.filename}`,
`\t${entry.content}`,
].filter(Boolean).join("\n")
).join("\n")
}

test("`blame()` parses `git blame --line-porcelain`", () => {
expect(blame("", { stdout: format(expected) })).toEqual(expected)
})
68 changes: 68 additions & 0 deletions git/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Imports
import type { Arrayable, Nullable } from "@libs/typing"
import { assert } from "@std/assert"
import * as semver from "@std/semver"
import * as git from "./mod.ts"
export type { Arrayable, Nullable }
export type * from "@std/semver"

/**
* Generate a changelog based on the commits since the last version bump.
*
* It is automatically determined by the list of commits since the last edit of the "version" key.
* Commits must follow the conventional commits syntax (non-conventional commits are ignored).
*/
export function changelog(path: string, { scopes = [], minor = ["feat"], patch = ["fix", "docs", "perf", "refactor", "chore"], ...options } = {} as ChangelogOptions): Changelog {
// Find the base commit (last version bump)
const base = git.blame(path).find(({ content }) => /"version": "\d\.\d\.\d.*"/.test(content))
assert(base, `Could not find "version" key in ${path}`)

// Parse the version
const version = semver.parse(base.content.match(/"version": "(?<version>\d\.\d\.\d.*)"/)?.groups?.version ?? "")
const commits = git.log(base.sha, { filter: { conventional: true, scopes } })
const result = { version: { bump: null, current: version, next: version }, changelog: "" } as Changelog

// Bump the version
if (commits.some(({ breaking }) => breaking)) {
result.version.bump = "major"
result.version.next = semver.increment(version, result.version.bump)
} else if (commits.some(({ type }) => minor.includes(type as string))) {
result.version.bump = "minor"
result.version.next = semver.increment(version, result.version.bump)
} else if (commits.some(({ type }) => patch.includes(type as string))) {
result.version.bump = "patch"
result.version.next = semver.increment(version, result.version.bump)
}

// Write the new version
if (options.write) {
const content = Deno.readTextFileSync(path)
Deno.writeTextFileSync(path, content.replace(/"version": "\d\.\d\.\d.*"/, `"version": "${semver.format(result.version.next)}"`))
}

// Generate the changelog
result.changelog = commits.map(({ summary }) => summary).join("\n")
return result
}

/** Options for {@linkcode changelog()}. */
export type ChangelogOptions = {
/** The scopes to filter. */
scopes?: Arrayable<string>
/** The type of commits that increase the "patch" component of semver. */
patch?: Arrayable<string>
/** The type of commits that increase the "minor" component of semver. */
minor?: Arrayable<string>
/** Whether to update the version in the file. */
write?: boolean
}

/** Changelog. */
export type Changelog = {
version: {
bump: Nullable<"major" | "minor" | "patch">
current: semver.SemVer
next: semver.SemVer
}
changelog: string
}
26 changes: 26 additions & 0 deletions git/deno.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"icon": "🟰",
"name": "@libs/git",
"version": "0.1.0",
"imports": {
"@std/assert": "jsr:@std/assert@1",
"@std/semver": "jsr:@std/semver@1"
},
"exports": {
".": "./mod.ts",
"./blame": "./blame.ts",
"./log": "./log.ts",
"./changelog": "./changelog.ts"
},
"test:permissions": {
"env": [
"LOG_LEVEL"
],
"run": [
"git"
]
},
"supported": {
"deno": true
}
}
80 changes: 80 additions & 0 deletions git/log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Imports
import type { Arrayable, Nullable } from "@libs/typing"
import { command } from "@libs/run/command"
export type { Arrayable, Nullable }

/**
* Parse the output of `git log --pretty=<<%H>> <<%at>> <<%an>> %s`.
*/
export function log(sha: string, { stdout = "", ...options } = {} as LogOptions): LogEntry[] {
if (!stdout) {
;({ stdout } = command("git", ["log", "--pretty=<<%H>> <<%at>> <<%an>> %s", `${sha}..${options?.head ?? ""}`], { sync: true, throw: true }))
}
// Parse entries
let entries = []
for (const line of stdout.trim().split("\n")) {
const { sha, author, time, summary } = line.match(/^<<(?<sha>.{40})>> <<(?<time>\d+)>> <<(?<author>.*)>> (?<summary>.*)$/)!.groups!
const match = summary.match(/^(?<type>[^\(\):]+)(?:\((?<scopes>[^\(\):]+)\))?(?<breaking>!?):\s+(?<subject>[\s\S]*)/)?.groups
entries.push({
sha,
author,
time: Number(time),
conventional: !!match,
type: match?.type ?? null,
scopes: match?.scopes?.split(",").map((scope) => scope.trim()) ?? [],
breaking: match ? !!match?.breaking : null,
subject: match?.subject ?? summary,
summary,
})
}
// Filter entries
if (options?.filter?.conventional) {
entries = entries.filter(({ conventional }) => conventional)
}
if (options?.filter?.breaking) {
entries = entries.filter(({ breaking }) => breaking)
}
if (options?.filter?.types) {
entries = entries.filter(({ type }) => [options?.filter?.types].filter(Boolean).flat().includes(type as string))
}
if (options?.filter?.scopes?.length) {
entries = entries.filter(({ scopes }) => scopes.some((scope) => [options?.filter?.scopes].filter(Boolean).flat().includes(scope)))
}
return entries
}

/** Options for {@linkcode log()}. */
export type LogOptions = {
/**
* The output returned by `git log --pretty=<<%H>> <<%at>> <<%an>> %s`.
*
* If empty, the function will run `git log --pretty=<<%H>> <<%at>> <<%an>> %s` synchronously to populate this field.
*/
stdout?: string
/** The commit sha to compare against. */
head?: string
/** Filter the entries. */
filter?: {
/** Filter only conventional commits. */
conventional?: boolean
/** Filter by commit types (must follow conventional commits syntax). */
types?: Arrayable<string>
/** Filter by scopes (must follow conventional commits syntax). */
scopes?: Arrayable<string>
/** Filter by breaking changes (must follow conventional commits syntax). */
breaking?: boolean
}
}

/** Git log entry. */
export type LogEntry = {
sha: string
author: string
time: number
conventional: boolean
type: Nullable<string>
scopes: string[]
breaking: Nullable<boolean>
subject: string
summary: string
}
57 changes: 57 additions & 0 deletions git/log_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { log, type LogEntry } from "./log.ts"
import { expect, test } from "@libs/testing"

const expected = [
{
sha: "a".repeat(40),
author: "foo",
time: 1,
conventional: true,
type: "chore",
scopes: [],
breaking: false,
subject: "initial commit",
summary: "chore: initial commit",
},
{
sha: "b".repeat(40),
author: "bar",
time: 2,
conventional: true,
type: "fix",
scopes: ["a", "b"],
breaking: false,
subject: "add a comment",
summary: "fix(a, b): add a comment",
},
{
sha: "c".repeat(40),
author: "baz",
time: 3,
conventional: true,
type: "fix",
scopes: ["c"],
breaking: true,
subject: "fix a bug",
summary: "fix(c)!: fix a bug",
},
{
sha: "d".repeat(40),
author: "qux",
time: 4,
conventional: false,
type: null,
scopes: [],
breaking: null,
subject: "random commit",
summary: "random commit",
},
] as LogEntry[]

function format(entries: LogEntry[]) {
return entries.map((entry) => `<<${entry.sha}>> <<${entry.time}>> <<${entry.author}>> ${entry.conventional ? `${entry.type}${entry.scopes?.length ? `(${entry.scopes.join(", ")})` : ""}${entry.breaking ? "!" : ""}: ${entry.subject}` : entry.subject}`).join("\n")
}

test("`log()` parses `git log --pretty=<<%H>> <<%at>> <<%an>> %s`", () => {
expect(log("", { stdout: format(expected) })).toEqual(expected)
})
3 changes: 3 additions & 0 deletions git/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./blame.ts"
export * from "./log.ts"
export * from "./changelog.ts"
1 change: 1 addition & 0 deletions git/mod_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import "./mod.ts"
10 changes: 10 additions & 0 deletions git/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@

{
"type": "module",
"description": "Git parsing and manipulation",
"keywords": [
"git",
"patch",
"esm"
]
}
Loading

0 comments on commit 1887265

Please sign in to comment.