Folo/packages/changelog-cli/git-tools.ts

180 lines
4.3 KiB
TypeScript

import { execSync } from "node:child_process"
import { fileURLToPath } from "node:url"
import { dirname, join } from "pathe"
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = join(__dirname, "..", "..")
export interface GitCommit {
hash: string
shortHash: string
subject: string
body: string
author: string
email: string
date: string
pullRequest?: string
}
export interface GitTag {
name: string
hash: string
date: string
}
export class GitTools {
private workingDir: string
constructor(workingDir = projectRoot) {
this.workingDir = workingDir
}
/**
* Execute git command and return output
*/
private exec(command: string): string {
try {
return execSync(command, {
cwd: this.workingDir,
encoding: "utf-8",
}).trim()
} catch (error) {
console.error(`Git command failed: ${command}`)
throw error
}
}
/**
* Get the latest tag for mobile releases
*/
getLatestMobileTag(): GitTag | null {
try {
const tags = this.exec('git tag --sort=-version:refname | grep "^mobile@" | head -1')
if (!tags) return null
const tagName = tags.trim()
const hash = this.exec(`git rev-list -n 1 ${tagName}`)
const date = this.exec(`git log -1 --format=%ai ${hash}`)
return {
name: tagName,
hash,
date,
}
} catch {
return null
}
}
/**
* Get current branch name
*/
getCurrentBranch(): string {
return this.exec("git rev-parse --abbrev-ref HEAD")
}
/**
* Get the latest commit hash on current branch
*/
getLatestCommitHash(): string {
return this.exec("git rev-parse HEAD")
}
/**
* Get commits between two references
*/
getCommitsBetween(fromRef: string, toRef: string): GitCommit[] {
const format = [
"%H", // full hash
"%h", // short hash
"%s", // subject
"%b", // body
"%an", // author name
"%ae", // author email
"%ai", // author date
].join("%x1f") // use ASCII unit separator
const command = `git log --format="${format}%x1e" ${fromRef}..${toRef}`
const output = this.exec(command)
if (!output) return []
return output
.split("\x1e")
.filter(Boolean)
.map((entry) => {
const parts = entry.split("\x1f")
const [hash, shortHash, subject, body, author, email, date] = parts
// Ensure all required fields exist
if (!hash || !shortHash || !subject || !author || !email || !date) {
throw new Error("Invalid git log entry format")
}
// Check if commit is related to a pull request
const pullRequest = this.extractPullRequestNumber(subject, body || "")
return {
hash: hash.trim(),
shortHash: shortHash.trim(),
subject: subject.trim(),
body: (body || "").trim(),
author: author.trim(),
email: email.trim(),
date: date.trim(),
pullRequest,
}
})
}
/**
* Extract pull request number from commit message
*/
private extractPullRequestNumber(subject: string, body: string): string | undefined {
const text = `${subject} ${body}`
const prMatch = text.match(/#(\d+)/)
return prMatch ? prMatch[1] : undefined
}
/**
* Get all unique authors from commits, excluding bots and internal members
*/
getUniqueAuthors(commits: GitCommit[], internalMembers: string[]): string[] {
const authors = new Set<string>()
commits.forEach((commit) => {
const author = commit.author.toLowerCase()
const isBotOrInternal =
author.includes("[bot]") ||
author.includes("bot") ||
internalMembers.some((member) => author.includes(member.toLowerCase()))
if (!isBotOrInternal) {
authors.add(commit.author)
}
})
return Array.from(authors).sort()
}
/**
* Check if we're on a release branch
*/
isOnReleaseBranch(): boolean {
const currentBranch = this.getCurrentBranch()
return currentBranch.startsWith("release/")
}
/**
* Get the version from release branch name
*/
getVersionFromBranch(): string | null {
const currentBranch = this.getCurrentBranch()
const match = currentBranch.match(/release\/mobile\/(.+)/)
return match?.[1] ?? null
}
}
export default GitTools