Skip to content

Commit a203bb3

Browse files
committed
fix(scm): 🐛 address commit message provider review feedback
1 parent 12c90eb commit a203bb3

6 files changed

Lines changed: 158 additions & 9 deletions

File tree

src/i18n/locales/en/common.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,9 @@
270270
"generationFailed": "Zoo: Failed to generate commit message: {{errorMessage}}",
271271
"contextWarnings": "Zoo: Git context warning: {{warnings}}",
272272
"generatingFromUnstaged": "Zoo: Generating message using unstaged changes",
273+
"confirmUnstaged": "No staged changes found. Generate a commit message from {{count}} unstaged/untracked changes instead?",
274+
"confirmUnstagedAction": "Generate from unstaged changes",
275+
"useUnstagedConfirm": "No staged changes were found. Generate a commit message from unstaged changes instead?",
273276
"activationFailed": "Zoo: Failed to activate message generator: {{error}}",
274277
"providerRegistered": "Zoo: Commit message provider registered",
275278
"initializing": "Initializing...",

src/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@
218218
"scm/input": [
219219
{
220220
"command": "zoo-code.generateCommitMessage",
221+
"when": "scmProvider == git",
221222
"group": "navigation"
222223
}
223224
],

src/services/commit-message/CommitMessageProvider.ts

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as path from "path"
12
import * as vscode from "vscode"
23
import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager"
34
import { t } from "../../i18n"
@@ -8,13 +9,17 @@ import { CommitMessageGenerator } from "./CommitMessageGenerator"
89
import { getCommitMessageGitContextSettings, toGitContextCollectorOptions } from "./gitContextSettings"
910

1011
interface VscGenerationRequest {
12+
/** Source control input box that should receive the generated message. */
1113
inputBox: { value: string }
14+
/** Root URI supplied by VS Code for the source control command invocation. */
1215
rootUri?: vscode.Uri
1316
}
1417

18+
/** Registers and handles the VS Code command that writes AI commit messages into SCM input. */
1519
export class CommitMessageProvider implements vscode.Disposable {
1620
private generator: CommitMessageGenerator
1721

22+
/** Creates the provider and wires it to the extension settings store. */
1823
constructor(
1924
private context: vscode.ExtensionContext,
2025
private outputChannel: vscode.OutputChannel,
@@ -24,6 +29,7 @@ export class CommitMessageProvider implements vscode.Disposable {
2429
this.generator = new CommitMessageGenerator(providerSettingsManager)
2530
}
2631

32+
/** Registers the generate commit message command with VS Code. */
2733
public async activate(): Promise<void> {
2834
this.outputChannel.appendLine(t("common:commitMessage.activated"))
2935

@@ -36,6 +42,7 @@ export class CommitMessageProvider implements vscode.Disposable {
3642
this.context.subscriptions.push(...disposables)
3743
}
3844

45+
/** Handles the command invocation from VS Code's SCM UI. */
3946
private async handleVSCodeCommand(vsRequest?: VscGenerationRequest): Promise<void> {
4047
try {
4148
const workspacePath = this.determineWorkspacePath(vsRequest?.rootUri)
@@ -72,7 +79,6 @@ export class CommitMessageProvider implements vscode.Disposable {
7279
vscode.window.showInformationMessage(t("common:commitMessage.noChanges"))
7380
return
7481
}
75-
7682
reportProgress(25, t("common:commitMessage.foundChanges", { count: resolution.changes.length }))
7783

7884
if (!resolution.usedStaged) {
@@ -94,10 +100,14 @@ export class CommitMessageProvider implements vscode.Disposable {
94100
}
95101

96102
reportProgress(70, t("common:commitMessage.generating"))
103+
const gitContext = this.appendExistingCommitMessageDraft(
104+
gitContextResult.context,
105+
targetRepository.inputBox.value,
106+
)
97107
const message = await this.generator.generateMessage({
98108
workspacePath,
99109
selectedFiles: resolution.files,
100-
gitContext: gitContextResult.context,
110+
gitContext,
101111
onProgress: (update) => {
102112
if (update.percentage !== undefined) {
103113
reportProgress(70 + update.percentage * 0.25, update.message)
@@ -118,6 +128,7 @@ export class CommitMessageProvider implements vscode.Disposable {
118128
}
119129
}
120130

131+
/** Resolves staged changes, asking before falling back to unstaged worktree changes. */
121132
private async resolveCommitChanges(gitCollector: GitContextCollector): Promise<{
122133
changes: GitChange[]
123134
files: string[]
@@ -127,6 +138,15 @@ export class CommitMessageProvider implements vscode.Disposable {
127138
let usedStaged = true
128139

129140
if (changes.length === 0) {
141+
const useUnstaged = await this.confirmUnstagedGeneration()
142+
if (!useUnstaged) {
143+
return {
144+
changes: [],
145+
files: [],
146+
usedStaged,
147+
}
148+
}
149+
130150
changes = await gitCollector.gatherChanges({ staged: false })
131151
usedStaged = false
132152
}
@@ -138,6 +158,7 @@ export class CommitMessageProvider implements vscode.Disposable {
138158
}
139159
}
140160

161+
/** Finds the Git repository that owns the requested workspace path. */
141162
private async determineTargetRepository(workspacePath: string): Promise<VscGenerationRequest | null> {
142163
try {
143164
const gitExtension = vscode.extensions.getExtension("vscode.git")
@@ -154,18 +175,31 @@ export class CommitMessageProvider implements vscode.Disposable {
154175
return null
155176
}
156177

157-
for (const repo of gitApi.repositories ?? []) {
158-
if (repo.rootUri && workspacePath.startsWith(repo.rootUri.fsPath)) {
159-
return repo
160-
}
178+
const repositories = gitApi.repositories ?? []
179+
const matchingRepositories = repositories
180+
.filter((repo: VscGenerationRequest) =>
181+
repo.rootUri ? isPathWithinRepository(workspacePath, repo.rootUri.fsPath) : false,
182+
)
183+
.sort(
184+
(a: VscGenerationRequest, b: VscGenerationRequest) =>
185+
(b.rootUri?.fsPath.length ?? 0) - (a.rootUri?.fsPath.length ?? 0),
186+
)
187+
188+
if (matchingRepositories.length > 0) {
189+
return matchingRepositories[0]
190+
}
191+
192+
if (repositories.length === 1) {
193+
return repositories[0]
161194
}
162195

163-
return gitApi.repositories[0] ?? null
196+
return null
164197
} catch (error) {
165198
return null
166199
}
167200
}
168201

202+
/** Derives the workspace path from the SCM resource or active workspace. */
169203
private determineWorkspacePath(resourceUri?: vscode.Uri): string {
170204
if (resourceUri) {
171205
return resourceUri.fsPath
@@ -179,5 +213,41 @@ export class CommitMessageProvider implements vscode.Disposable {
179213
throw new Error("Could not determine workspace path")
180214
}
181215

216+
/** Adds an existing commit input draft to the model context so the next message can improve it. */
217+
private appendExistingCommitMessageDraft(gitContext: string, existingDraft: string): string {
218+
const normalizedDraft = existingDraft.trim()
219+
if (!normalizedDraft) {
220+
return gitContext
221+
}
222+
223+
return `${gitContext}
224+
225+
## Existing Commit Message Draft
226+
The Git commit input already contains this draft. Use it as guidance and generate the best final commit message for the changes. You may improve, replace, or preserve parts of it as appropriate.
227+
228+
\`\`\`
229+
${normalizedDraft}
230+
\`\`\``
231+
}
232+
233+
/** Confirms whether unstaged changes may be gathered when there are no staged changes. */
234+
private async confirmUnstagedGeneration(): Promise<boolean> {
235+
const confirmAction = t("common:commitMessage.confirmUnstagedAction")
236+
const choice = await vscode.window.showWarningMessage(
237+
t("common:commitMessage.useUnstagedConfirm"),
238+
{ modal: true },
239+
confirmAction,
240+
)
241+
242+
return choice === confirmAction
243+
}
244+
245+
/** Keeps provider cleanup compatible with VS Code disposable registration. */
182246
public dispose(): void {}
183247
}
248+
249+
/** Returns true when the target path is the repository root or is contained by it. */
250+
export function isPathWithinRepository(targetPath: string, repositoryPath: string): boolean {
251+
const relativePath = path.relative(path.resolve(repositoryPath), path.resolve(targetPath))
252+
return relativePath === "" || (!!relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath))
253+
}
Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
import * as path from "path"
2+
import * as vscode from "vscode"
23

3-
import { isPathWithinRepository } from "../CommitMessageProvider"
4+
import { CommitMessageProvider, isPathWithinRepository } from "../CommitMessageProvider"
45

5-
vi.mock("vscode", () => ({}))
6+
vi.mock("vscode", () => ({
7+
window: {
8+
showWarningMessage: vi.fn(),
9+
},
10+
}))
611

712
describe("CommitMessageProvider", () => {
13+
const createProvider = () =>
14+
new CommitMessageProvider(
15+
{} as vscode.ExtensionContext,
16+
{ appendLine: vi.fn() } as unknown as vscode.OutputChannel,
17+
)
18+
19+
beforeEach(() => {
20+
vi.clearAllMocks()
21+
})
22+
823
it("matches repository roots by path containment instead of string prefix", () => {
924
const root = path.parse(process.cwd()).root
1025
const repositoryPath = path.join(root, "work", "app")
@@ -13,4 +28,61 @@ describe("CommitMessageProvider", () => {
1328
expect(isPathWithinRepository(repositoryPath, repositoryPath)).toBe(true)
1429
expect(isPathWithinRepository(path.join(root, "work", "application"), repositoryPath)).toBe(false)
1530
})
31+
32+
it("adds existing commit input to the generation context", () => {
33+
const provider = createProvider()
34+
const gitContext = "diff --git a/src/file.ts b/src/file.ts"
35+
36+
const contextWithDraft = (provider as any).appendExistingCommitMessageDraft(gitContext, "existing message")
37+
38+
expect(contextWithDraft).toContain(gitContext)
39+
expect(contextWithDraft).toContain("## Existing Commit Message Draft")
40+
expect(contextWithDraft).toContain("existing message")
41+
})
42+
43+
it("does not add empty commit input to the generation context", () => {
44+
const provider = createProvider()
45+
const gitContext = "diff --git a/src/file.ts b/src/file.ts"
46+
47+
expect((provider as any).appendExistingCommitMessageDraft(gitContext, " ")).toBe(gitContext)
48+
})
49+
50+
it("asks before falling back to unstaged changes", async () => {
51+
vi.mocked(vscode.window.showWarningMessage).mockResolvedValue("commitMessage.confirmUnstagedAction" as never)
52+
const provider = createProvider()
53+
const gitCollector = {
54+
gatherChanges: vi
55+
.fn()
56+
.mockResolvedValueOnce([])
57+
.mockResolvedValueOnce([{ filePath: "src/file.ts" }]),
58+
}
59+
60+
const resolution = await (provider as any).resolveCommitChanges(gitCollector)
61+
62+
expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(
63+
"commitMessage.useUnstagedConfirm",
64+
{ modal: true },
65+
"commitMessage.confirmUnstagedAction",
66+
)
67+
expect(gitCollector.gatherChanges).toHaveBeenNthCalledWith(1, { staged: true })
68+
expect(gitCollector.gatherChanges).toHaveBeenNthCalledWith(2, { staged: false })
69+
expect(resolution).toEqual({
70+
changes: [{ filePath: "src/file.ts" }],
71+
files: ["src/file.ts"],
72+
usedStaged: false,
73+
})
74+
})
75+
76+
it("does not read unstaged changes when fallback is declined", async () => {
77+
vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(undefined)
78+
const provider = createProvider()
79+
const gitCollector = {
80+
gatherChanges: vi.fn().mockResolvedValueOnce([]),
81+
}
82+
83+
const resolution = await (provider as any).resolveCommitChanges(gitCollector)
84+
85+
expect(gitCollector.gatherChanges).toHaveBeenCalledTimes(1)
86+
expect(resolution).toEqual({ changes: [], files: [], usedStaged: true })
87+
})
1688
})

src/services/commit-message/gitContextSettings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { defaultCommitMessageGitContextSettings, type CommitMessageGitContextSet
33
import { ContextProxy } from "../../core/config/ContextProxy"
44
import type { GitContextCollectorOptions } from "../git-context"
55

6+
/** Reads and normalizes the persisted Git context settings for commit message generation. */
67
export function getCommitMessageGitContextSettings(): Required<CommitMessageGitContextSettings> {
78
const rawSettings = ContextProxy.instance.getValue("commitMessageGitContext") as
89
| CommitMessageGitContextSettings
@@ -38,6 +39,7 @@ export function normalizeCommitMessageGitContextSettings(
3839
}
3940
}
4041

42+
/** Converts commit-message settings into options consumed by the Git context collector. */
4143
export function toGitContextCollectorOptions(
4244
staged: boolean,
4345
settings: Required<CommitMessageGitContextSettings>,

src/services/commit-message/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as vscode from "vscode"
22
import { CommitMessageProvider } from "./CommitMessageProvider"
33
import { t } from "../../i18n"
44

5+
/** Registers the commit message provider and reports activation failures to the output channel. */
56
export function registerCommitMessageProvider(
67
context: vscode.ExtensionContext,
78
outputChannel: vscode.OutputChannel,

0 commit comments

Comments
 (0)