refactor(git,commitGenerator): add git context with stat and improve prompt structure

- introduce `getGitContext` returning both diff and name-status stat output
- update `generateForRepository` and `performGeneration` to consume new context
- prefix prompt with changed files stat block when available
- replace `commitStyle` config key with `promptVersion` using `LATEST_PROMPT_VERSION`
- fix first-commit diff logic to use else-branch instead of fallthrough checks
- update extension icon
This commit is contained in:
2026-04-24 12:22:01 -04:00
parent f5f6a2f703
commit 4471c2b2b2
5 changed files with 94 additions and 21 deletions
+7 -6
View File
@@ -1,6 +1,7 @@
import * as path from "path"
import * as vscode from "vscode"
import { getGitDiffStagedFirst } from "./git"
import { getGitContext, getGitDiffStagedFirst } from "./git"
import { LATEST_PROMPT_VERSION } from "./prompts"
import { createProvider } from "./providers"
let abortController: AbortController | undefined
@@ -99,7 +100,7 @@ async function filterReposWithChanges(repos: unknown[]): Promise<unknown[]> {
async function generateForRepository(repository: any): Promise<void> {
const repoPath = repository.rootUri.fsPath
const repoName = repoPath.split(path.sep).pop() || "repository"
const diff = await getGitDiffStagedFirst(repoPath)
const { diff, stat } = await getGitContext(repoPath)
await vscode.window.withProgress(
{
@@ -107,26 +108,26 @@ async function generateForRepository(repository: any): Promise<void> {
title: `Zemit : Génération du message de commit pour ${repoName}...`,
cancellable: true,
},
(_progress, token) => performGeneration(repository.inputBox, diff, token),
(_progress, token) => performGeneration(repository.inputBox, diff, stat, token),
)
}
async function performGeneration(
inputBox: any,
diff: string,
stat: string,
token: vscode.CancellationToken,
): Promise<void> {
const config = vscode.workspace.getConfiguration("zemit")
const maxDiffSize = config.get<number>("maxDiffSize", 5000)
const style = config.get<string>("commitStyle", "conventional")
const style = config.get<string>("promptVersion", LATEST_PROMPT_VERSION)
const truncatedDiff =
diff.length > maxDiffSize ? diff.substring(0, maxDiffSize) + "\n\n[Diff truncated due to size]" : diff
// Include any existing user note in the prompt
const existingNote = inputBox.value?.trim() || ""
let prompt = truncatedDiff
let prompt = stat ? `## Changed files\n${stat}\n\n## Diff\n${truncatedDiff}` : truncatedDiff
if (existingNote) {
prompt = `Developer note (use if relevant): ${existingNote}\n\n${prompt}`
}
+60 -10
View File
@@ -43,17 +43,15 @@ export async function getGitDiff(cwd: string, stagedOnly = false): Promise<strin
if (await hasCommits(cwd)) {
const { stdout: staged } = await execAsync("git --no-pager diff --staged --diff-filter=d", { cwd })
diff = staged.trim()
}
if (!stagedOnly && !diff) {
const { stdout: unstaged } = await execAsync("git --no-pager diff HEAD --diff-filter=d", { cwd })
diff = unstaged.trim()
}
// New repo with no commits yet — show everything that's staged/tracked
if (!diff) {
const { stdout: newRepo } = await execAsync("git --no-pager diff --cached --diff-filter=d", { cwd })
diff = newRepo.trim()
if (!stagedOnly && !diff) {
const { stdout: unstaged } = await execAsync("git --no-pager diff HEAD --diff-filter=d", { cwd })
diff = unstaged.trim()
}
} else {
// First commit — no HEAD exists yet
const { stdout: cached } = await execAsync("git --no-pager diff --cached --diff-filter=d", { cwd })
diff = cached.trim()
}
// Include untracked (new) files when not limiting to staged only
@@ -85,3 +83,55 @@ export async function getGitDiffStagedFirst(cwd: string): Promise<string> {
return await getGitDiff(cwd, false)
}
}
export async function getGitContext(cwd: string): Promise<{ diff: string; stat: string }> {
if (!(await isGitInstalled())) throw new Error("Git n'est pas installé")
if (!(await isGitRepo(cwd))) throw new Error("Pas un dépôt git")
let diff = ""
let stat = ""
let includeUntracked = false
if (await hasCommits(cwd)) {
const { stdout: stagedDiff } = await execAsync("git --no-pager diff --staged --diff-filter=d", { cwd })
diff = stagedDiff.trim()
if (diff) {
const { stdout: stagedStat } = await execAsync("git --no-pager diff --staged --name-status", { cwd })
stat = stagedStat.trim()
} else {
includeUntracked = true
const { stdout: allDiff } = await execAsync("git --no-pager diff HEAD --diff-filter=d", { cwd })
diff = allDiff.trim()
const { stdout: allStat } = await execAsync("git --no-pager diff HEAD --name-status", { cwd })
stat = allStat.trim()
}
} else {
// First commit — no HEAD exists yet
includeUntracked = true
const { stdout: cachedDiff } = await execAsync("git --no-pager diff --cached --diff-filter=d", { cwd })
diff = cachedDiff.trim()
const { stdout: cachedStat } = await execAsync("git --no-pager diff --cached --name-status", { cwd })
stat = cachedStat.trim()
}
if (includeUntracked) {
const { stdout: untrackedList } = await execAsync("git ls-files --others --exclude-standard", { cwd })
const untrackedFiles = untrackedList.trim().split("\n").filter(Boolean)
for (const file of untrackedFiles) {
const result = await execAsync(
`git --no-pager diff --no-index -- /dev/null ${JSON.stringify(file)}`,
{ cwd },
).catch((e: any) => ({ stdout: e.stdout as string | undefined }))
if (result.stdout) diff += (diff ? "\n" : "") + result.stdout
stat += (stat ? "\n" : "") + `A\t${file}`
}
}
diff = diff.trim()
stat = stat.trim()
if (!diff) throw new Error("Aucune modification trouvée pour générer un message de commit")
return { diff, stat }
}
+24 -2
View File
@@ -1,7 +1,9 @@
export const LATEST_PROMPT_VERSION = "zemit-v2"
export const SYSTEM_PROMPT =
"You are a git commit message generator. Output only the commit message — no explanation, no preamble, no backticks, no quotes. All messages must be written in English."
export const CONVENTIONAL_INSTRUCTION = `Based on the provided git diff, generate a concise and descriptive commit message following the Conventional Commits format:
export const ZEMIT_V1 = `Based on the provided git diff, generate a concise and descriptive commit message following the Conventional Commits format:
<type>(<scope>): <short description>
@@ -23,4 +25,24 @@ Rules:
- For breaking changes, add ! after the type and a BREAKING CHANGE: footer
- Only include a body if there are multiple distinct changes to explain; for a single focused change, output the title only`
export const SIMPLE_INSTRUCTION = `Based on the provided git diff, generate a short and clear one-line commit message (50-72 characters).`
export const ZEMIT_V2 = `Generate a git commit message following Conventional Commits format:
<type>(<scope>): <short description>
Types: feat | fix | refactor | style | docs | test | chore | perf
Scope: optional, affected module (e.g. auth, api, ui, config)
Description: lowercase, imperative mood, no trailing period
Body rules:
- Single focused change → title only
- Multiple files or distinct concerns → title + blank line + concise bullet body listing each change
- Breaking change: add ! after type + "BREAKING CHANGE: <detail>" footer`
const PROMPT_REGISTRY: Record<string, string> = {
"zemit-v1": ZEMIT_V1,
"zemit-v2": ZEMIT_V2,
}
export function getPrompt(version: string): string {
return PROMPT_REGISTRY[version] ?? PROMPT_REGISTRY[LATEST_PROMPT_VERSION]
}
+3 -3
View File
@@ -1,5 +1,5 @@
import * as vscode from "vscode"
import { SYSTEM_PROMPT, CONVENTIONAL_INSTRUCTION, SIMPLE_INSTRUCTION } from "./prompts"
import { SYSTEM_PROMPT, getPrompt } from "./prompts"
export interface AIProvider {
generateCommitMessage(diff: string, style: string, signal: AbortSignal): AsyncIterable<string>
@@ -15,7 +15,7 @@ class OpenAICompatibleProvider implements AIProvider {
) {}
async *generateCommitMessage(diff: string, style: string, signal: AbortSignal): AsyncIterable<string> {
const instruction = style === "conventional" ? CONVENTIONAL_INSTRUCTION : SIMPLE_INSTRUCTION
const instruction = getPrompt(style)
const url = `${this.baseUrl}/chat/completions`
const response = await fetch(url, {
@@ -58,7 +58,7 @@ class AnthropicProvider implements AIProvider {
) {}
async *generateCommitMessage(diff: string, style: string, signal: AbortSignal): AsyncIterable<string> {
const instruction = style === "conventional" ? CONVENTIONAL_INSTRUCTION : SIMPLE_INSTRUCTION
const instruction = getPrompt(style)
const url = `${this.baseUrl}/messages`
const response = await fetch(url, {