-
-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
f0412dd
commit 1887265
Showing
12 changed files
with
371 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
import "./mod.ts" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
] | ||
} |
Oops, something went wrong.