From 514d2e82da2624fcd1ec62f4615a053875363110 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 01:52:19 +0300 Subject: [PATCH] feat(git): follow repository style when generating commit and PR text Commit generation now samples the last 10 commit subjects and instructs the model to match their language, prefix convention, and length, falling back to Conventional Commits only when the history shows no consistent style. PR generation probes the conventional pull request template locations and reuses the repository's own sections and checklists as the body structure when one is found. Claude-Session: https://claude.ai/code/session_01NzK8hD1hFHHHwv2FgZJwC2 --- CHANGELOG.md | 2 + packages/ui/src/lib/gitApi.ts | 84 +++++++++++++++++++++++++++++ packages/ui/src/lib/magicPrompts.ts | 16 ++++-- 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03cf8954..0f4a2a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - **Dictation:** speech is now transcribed after you stop talking, instead of being re-guessed word by word while you speak. The offline models OpenChamber runs are built to read a whole utterance at once, so the running transcript was consistently worse than the final one. While recording, the composer shows a live waveform of your voice and a timer, then Transcribing while the text is produced. Long recordings are split at pauses in your speech rather than on a timer, so a three-minute dictation still returns a few seconds after you stop, and words are no longer cut in half at the split. +- Git: generated commit messages now follow the style of the repository's recent commits, including their language, so repositories that commit in Korean or without a `feat:`-style prefix get messages that match instead of English Conventional Commits. +- Git: generating a pull request description now picks up the repository's own PR template when it has one, so the draft comes back in your project's sections and checklists instead of the built-in Summary/Why/Testing layout. - Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). ## [1.19.0] - 2026-08-19 diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 1192cb06..8e3c9d49 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -7,6 +7,7 @@ import { materializeOpenDraftSession, useSessionUIStore } from '@/sync/session-u import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { runtimeFetch } from '@/lib/runtime-fetch'; export type { GitRemote, @@ -247,6 +248,30 @@ const collectSelectedFileDiffs = async (directory: string, files: string[]): Pro return total; }; +const COMMIT_STYLE_SAMPLE_COUNT = 10; +const COMMIT_STYLE_SUBJECT_CHAR_LIMIT = 200; + +// Recent commit subjects give the model the repository's own commit style — +// language, prefixes, capitalization — instead of a hardcoded English default. +// A repository with no history yet is normal, so an empty sample is not an error. +const collectRecentCommitSubjects = async (directory: string): Promise => { + try { + const log = await getGitLog(directory, { maxCount: COMMIT_STYLE_SAMPLE_COUNT }); + const subjects = (Array.isArray(log?.all) ? log.all : []) + .map((entry) => (typeof entry?.message === 'string' ? entry.message.trim() : '')) + .filter(Boolean) + .map((subject) => subject.slice(0, COMMIT_STYLE_SUBJECT_CHAR_LIMIT)); + if (subjects.length === 0) return '(no commits yet)'; + return subjects.map((subject) => `- ${subject}`).join('\n'); + } catch (error) { + console.warn('[git-generation][browser] failed to collect recent commit subjects', { + directory, + error: error instanceof Error ? error.message : String(error), + }); + return '(recent commits unavailable)'; + } +}; + const parseCommitStructured = (structured: Record | null): { subject: string; highlights: string[] } => { const subject = typeof structured?.subject === 'string' ? structured.subject.trim() : ''; const highlights = Array.isArray(structured?.highlights) @@ -293,9 +318,11 @@ export async function generateCommitMessage( selectedFiles: files.length, }); + const recentCommits = await collectRecentCommitSubjects(directory); const visiblePrompt = await renderMagicPrompt('git.commit.generate.visible'); const hiddenPrompt = await renderMagicPrompt('git.commit.generate.instructions', { selected_files: files.map((file) => `- ${file}`).join('\n'), + recent_commits: recentCommits, }); try { @@ -355,6 +382,62 @@ export async function generateCommitMessage( } } +// Conventional pull request template locations. GitHub resolves `.github/` +// first, then the repository root, then `docs/`; both casings are probed +// because case-sensitive filesystems treat them as different files. GitLab +// keeps its merge request templates in `.gitlab/merge_request_templates/`, +// where `Default.md` is the one applied without an explicit choice. +const PULL_REQUEST_TEMPLATE_PATHS = [ + '.github/pull_request_template.md', + '.github/PULL_REQUEST_TEMPLATE.md', + 'pull_request_template.md', + 'PULL_REQUEST_TEMPLATE.md', + 'docs/pull_request_template.md', + 'docs/PULL_REQUEST_TEMPLATE.md', + '.gitlab/merge_request_templates/Default.md', +] as const; + +const PULL_REQUEST_TEMPLATE_CHAR_LIMIT = 8_000; + +const readOptionalRepoTextFile = async (directory: string, relativePath: string): Promise => { + const absolutePath = `${directory.replace(/\/+$/, '')}/${relativePath}`; + const runtimeFiles = getRegisteredRuntimeAPIs()?.files; + if (runtimeFiles?.readFile) { + try { + const result = await runtimeFiles.readFile(absolutePath, { optional: true, directory }); + return result.content ?? null; + } catch { + return null; + } + } + try { + const params = new URLSearchParams({ path: absolutePath, directory, optional: 'true' }); + const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' }); + if (!response.ok) return null; + return await response.text(); + } catch { + return null; + } +}; + +// A repository that ships a PR template expects descriptions in its shape, so +// the template wins over the built-in section layout. Missing template is the +// normal case, not a failure: probing stops at the first file that has content. +const collectPullRequestTemplate = async (directory: string): Promise => { + for (const relativePath of PULL_REQUEST_TEMPLATE_PATHS) { + const content = await readOptionalRepoTextFile(directory, relativePath); + const trimmed = content?.trim(); + if (!trimmed) continue; + console.info('[git-generation][browser] pull request template detected', { + directory, + template: relativePath, + length: trimmed.length, + }); + return `\nRepository pull request template (${relativePath}) — use it as the body structure:\n${trimmed.slice(0, PULL_REQUEST_TEMPLATE_CHAR_LIMIT)}`; + } + return ''; +}; + export async function generatePullRequestDescription( directory: string, payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } @@ -420,6 +503,7 @@ export async function generatePullRequestDescription( }).join('\n'), changed_files: changedFiles.length > 0 ? changedFiles.map((file) => `- ${file}`).join('\n') : '- none detected', additional_context_block: payload.context?.trim() ? `\nAdditional context:\n${payload.context.trim()}` : '', + pr_template_block: await collectPullRequestTemplate(directory), }); const parsePrStructured = (structured: Record | null) => ({ diff --git a/packages/ui/src/lib/magicPrompts.ts b/packages/ui/src/lib/magicPrompts.ts index 07a31208..a2bb430d 100644 --- a/packages/ui/src/lib/magicPrompts.ts +++ b/packages/ui/src/lib/magicPrompts.ts @@ -83,6 +83,7 @@ const MAGIC_PROMPT_DEFINITIONS: readonly MagicPromptDefinition[] = [ description: 'Hidden instructions for commit message generation.', placeholders: [ { key: 'selected_files', description: 'Bullet list of currently selected file paths.' }, + { key: 'recent_commits', description: 'Subjects of the most recent commits on the current branch.' }, ], template: `Return exactly one JSON object and nothing else. Do not include prose, markdown, explanations, or code fences. @@ -90,14 +91,17 @@ The JSON object must have exactly this shape: {"subject": string, "highlights": string[]} Rules: -- subject format: : -- allowed types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert -- no scope in subject +- match the style of the recent commits below: their language, capitalization, use or absence of a type prefix or scope, and typical length +- if the recent commits are written in a language other than English, write the subject and highlights in that language +- when the recent commits show no consistent style, use the format : with one of: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert, and no scope - keep subject concise and user-facing - highlights: 0-3 concise user-facing points - use double quotes for all JSON strings - do not include trailing commas or comments +Recent commits on this branch (newest first): +{{recent_commits}} + Selected files: {{selected_files}}`, }, @@ -119,6 +123,7 @@ Selected files: { key: 'commits', description: 'Bullet list of commits in base...head.' }, { key: 'changed_files', description: 'Bullet list of changed files in base...head.' }, { key: 'additional_context_block', description: 'Optional Additional context block (already formatted).' }, + { key: 'pr_template_block', description: 'Optional repository pull request template block (already formatted, empty when the repo has none).' }, ], template: `Return exactly one JSON object and nothing else. Do not include prose, markdown outside JSON, explanations, or code fences. @@ -127,7 +132,8 @@ The JSON object must have exactly this shape: Rules: - title: concise, outcome-first, conventional style -- body: markdown with sections: ## Summary, ## Why, ## Testing +- body: when a repository pull request template is included below, reuse it as the body — keep its headings, order, wording, comments stripped, and checklists, and fill each section from the commits and changed files; leave a section empty rather than inventing content for it +- body when no template is included: markdown with sections: ## Summary, ## Why, ## Testing - keep output concrete and user-facing - put all markdown inside the body string - use double quotes for all JSON strings and escape newlines as \\n @@ -140,7 +146,7 @@ Commits in range (base...head): {{commits}} Files changed across these commits: -{{changed_files}}{{additional_context_block}}`, +{{changed_files}}{{additional_context_block}}{{pr_template_block}}`, }, { id: 'github.pr.review.visible',