feat: add commit message generation feature

This commit is contained in:
Mark IJbema
2026-02-19 16:26:03 +01:00
parent cab30fb2b4
commit a7cc035b0b
17 changed files with 2236 additions and 0 deletions
@@ -0,0 +1,125 @@
import type { GitContext, FileChange } from "./types"
const LOCK_FILES = new Set([
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"Cargo.lock",
"poetry.lock",
"composer.lock",
"Gemfile.lock",
"go.sum",
"bun.lockb",
"bun.lock",
"uv.lock",
"Pipfile.lock",
"flake.lock",
"packages.lock.json",
"project.assets.json",
"paket.lock",
"pubspec.lock",
"Package.resolved",
"Podfile.lock",
"shrinkwrap.yaml",
])
const MAX_DIFF_LENGTH = 4000
function isLockFile(filepath: string): boolean {
const name = filepath.split("/").pop() ?? filepath
return LOCK_FILES.has(name)
}
function git(args: string[], cwd: string): string {
const result = Bun.spawnSync(["git", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
})
return result.stdout.toString().trim()
}
function parseNameStatus(output: string): Array<{ status: string; path: string }> {
if (!output) return []
return output.split("\n").map((line) => {
const [status, ...rest] = line.split("\t")
return { status: status!, path: rest.join("\t") }
})
}
function parsePorcelain(output: string): Array<{ status: string; path: string }> {
if (!output) return []
return output
.split("\n")
.filter((line) => line.length > 0)
.map((line) => {
const xy = line.slice(0, 2)
const filepath = line.slice(3)
return { status: xy.trim(), path: filepath }
})
}
function mapStatus(code: string): FileChange["status"] {
if (code.startsWith("R")) return "renamed"
if (code === "A" || code === "??" || code === "?") return "added"
if (code === "D") return "deleted"
if (code === "M") return "modified"
return "modified"
}
function isUntracked(code: string): boolean {
return code === "??" || code === "?"
}
export async function getGitContext(repoPath: string, selectedFiles?: string[]): Promise<GitContext> {
const branch = git(["branch", "--show-current"], repoPath) || "HEAD"
const log = git(["log", "--oneline", "-5"], repoPath)
const recentCommits = log ? log.split("\n") : []
// Check staged files first
const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath))
const useStaged = staged.length > 0
// Fall back to all changes if nothing staged
const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath))
const selected = selectedFiles ? new Set(selectedFiles) : undefined
const files: FileChange[] = []
for (const entry of raw) {
if (isLockFile(entry.path)) continue
if (selected && !selected.has(entry.path)) continue
const status = mapStatus(entry.status)
const untracked = isUntracked(entry.status)
let diff: string
if (untracked) {
diff = `New untracked file: ${entry.path}`
} else if (status === "deleted") {
diff = useStaged
? git(["diff", "--cached", "--", entry.path], repoPath)
: git(["diff", "--", entry.path], repoPath)
} else {
const raw = useStaged
? git(["diff", "--cached", "--", entry.path], repoPath)
: git(["diff", "--", entry.path], repoPath)
// Detect binary files
if (raw.includes("Binary files") || raw.includes("GIT binary patch")) {
diff = `Binary file ${entry.path} has been modified`
} else {
diff = raw
}
}
// Truncate large diffs
if (diff.length > MAX_DIFF_LENGTH) {
diff = diff.slice(0, MAX_DIFF_LENGTH) + "\n... [truncated]"
}
files.push({ status, path: entry.path, diff })
}
return { branch, recentCommits, files }
}