From 8d970ef77bec3325a51af6751c1048a5121deae8 Mon Sep 17 00:00:00 2001 From: sunbao <434309217@qq.com> Date: Fri, 9 Oct 2026 09:13:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(commit-message):=20=E5=A4=9A=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=B7=A5=E4=BD=9C=E5=8C=BA=E4=B8=8B=E6=8C=89=E5=B7=B2?= =?UTF-8?q?=E9=80=89=E4=BB=93=E5=BA=93=E7=94=9F=E6=88=90=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=20not-a-repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 生成提交信息是唯一没把「已选仓库」(repoRoot) 传到宿主的链路,工作区 根不是仓库时(并列多个仓库)必然失败: - 宿主半 `commitMessage()`:只用 `cwdOf(payload)`,随后 `summary({ cwd })` / `diff({ cwd })` 都不带 repoRoot;而宿主里其余各处 git 调用统一走 `repoRootOf(cwd, payload)`。于是 `git rev-parse --show-toplevel` 在工作区 根上失败 → 409 `not-a-repo`。 - 客户端半 `aiRun()`:手工拼 payload `{ cwd, sessionId, model, reasoningEffort, prompt }` 时漏了 repoRoot,而其它调用一律走 `Object.assign({}, base, …)`(`base = { cwd, repoRoot }`)。 修复:宿主按 `repoRootOf(cwd, payload)` 解析仓库并把 `{ cwd, repoRoot }` 传给 summary/diff;客户端把 `base` 下传 ChangesPanel 并用于该请求。 回归测试两处,均已在未修复代码上验证为**失败**: - `tests/api.test.mjs`:工作区 cwd 不是仓库 + repoRoot 指向一个干净仓库 → 400 `nothing to describe`(修复前 409 `not-a-repo`,因为干净仓库在调 模型前就返回,测试不消耗任何模型调用);不带 repoRoot 时仍 409,守卫 没有被放宽。 - `tests/smoke.mjs`:静态断言 `aiRun` 走 `Object.assign` 且带上 `props.base`,并断言 Panel 确实把 `base` 下传给了 ChangesPanel。 验证:`npm test` **106/106**(原 104/104 基线);真机 DSH 桌面 0.2.0-rc.2 上,同一路由带 repoRoot 由修复前的 409 `not-a-repo` 变为 400 `nothing to describe`,不带 repoRoot 仍为 409。 --- src/client.js | 22 ++++++++++++++-------- src/index.js | 13 ++++++++++--- tests/api.test.mjs | 40 ++++++++++++++++++++++++++++++++++++++++ tests/smoke.mjs | 15 +++++++++++++++ 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/src/client.js b/src/client.js index 80406f9..b2ae897 100644 --- a/src/client.js +++ b/src/client.js @@ -7980,13 +7980,19 @@ window.__ModuleLoader__.load({ setAiNote(null) try { const settings = props.commitSettings === undefined ? { model: '', reasoning: '', prompt: '' } : props.commitSettings - const data = await props.request('commit-message', { - cwd: props.cwd, - sessionId: props.sessionId, - model: settings.model, - reasoningEffort: settings.reasoning, - prompt: settings.prompt, - }) + /* `base` carries the picked repository ({ cwd, repoRoot }) exactly like + every other request: without it the host resolves the repository from + the session cwd, which need not be one (several sibling repos). */ + const data = await props.request('commit-message', Object.assign( + {}, + props.base === undefined ? { cwd: props.cwd } : props.base, + { + sessionId: props.sessionId, + model: settings.model, + reasoningEffort: settings.reasoning, + prompt: settings.prompt, + }, + )) setAiUndo(message === '' ? null : message) setMessage(typeof data.message === 'string' ? data.message : '') /* Say what the model did NOT see, instead of quietly sending less. */ @@ -9368,7 +9374,7 @@ window.__ModuleLoader__.load({ const commitSettings = readCommitSettings() const changesPane = E(ChangesPanel, { t: t, summary: summary, busy: busy, compact: compact, hideHeader: compact, - request: request, cwd: cwd, sessionId: sessionId, commitSettings: commitSettings, + request: request, cwd: cwd, sessionId: sessionId, commitSettings: commitSettings, base: base, showIgnored: showIgnored, onToggleIgnored: () => setShowIgnored((value) => !value), // Commit-and-push carries the confirm flag with it: the button already diff --git a/src/index.js b/src/index.js index 62d0bde..9c07dbe 100644 --- a/src/index.js +++ b/src/index.js @@ -1547,10 +1547,17 @@ async function commitMessage(payload) { commitBusy = true try { const cwd = cwdOf(payload) + /* The picked repository travels as `repoRoot`, and every other git call + threads it through `repoRootOf`. Resolving the root from `cwd` alone made + this the one route that answered not-a-repo in a workspace whose own root + is not a repository — a workspace holding several sibling repos, which is + exactly what the repo picker exists for. */ + const repoRoot = await repoRootOf(cwd, payload) + const scope = { cwd: cwd, repoRoot: repoRoot } const route = await commitRouteOf(payload) - const sums = await summary({ cwd: cwd }) - const stagedPatch = sums.changes.staged.length > 0 ? (await diff({ cwd: cwd, staged: true })).patch : '' - const input = commitInputOf(stagedPatch, (await diff({ cwd: cwd })).patch) + const sums = await summary(scope) + const stagedPatch = sums.changes.staged.length > 0 ? (await diff(Object.assign({}, scope, { staged: true }))).patch : '' + const input = commitInputOf(stagedPatch, (await diff(scope)).patch) if (input.files.length === 0) { if (input.dropped > 0) { throw new PanelError('only-ignored-changes', 'the only changes are lock files or binary files, which are not sent to the model; stage a source change first') diff --git a/tests/api.test.mjs b/tests/api.test.mjs index 6ba9453..390a49f 100644 --- a/tests/api.test.mjs +++ b/tests/api.test.mjs @@ -501,6 +501,46 @@ test('commit-message: the user prompt is appended as data and capped', async () assert.match(seen[0].system, /commit messages/, 'the built-in rules stay in the system prompt') }) +/* A workspace can hold several sibling repositories, so the session cwd is not + necessarily a repository itself. Every other git call carries the picked one + as `repoRoot`; commit-message resolved its root from `cwd` alone and answered + not-a-repo, which made the AI message the one action that could not work in a + multi-repo workspace while the rest of the panel did. */ +test('commit-message: the picked repository travels as repoRoot in a multi-repo workspace', async () => { + const workspace = mkdtempSync(join(tmpdir(), 'dsh-ide-git-ws-')) + const picked = mkdtempSync(join(tmpdir(), 'dsh-ide-git-picked-')) + try { + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: picked }) + execFileSync('git', ['config', 'core.autocrlf', 'false'], { cwd: picked }) + execFileSync('git', ['config', 'user.name', 'Check'], { cwd: picked }) + execFileSync('git', ['config', 'user.email', 'check@example.com'], { cwd: picked }) + writeFileSync(join(picked, 'a.txt'), 'one\n') + execFileSync('git', ['add', '-A'], { cwd: picked }) + execFileSync('git', ['commit', '-q', '-m', 'first'], { cwd: picked }) + + const seen = [] + const llm = fakeLlm([{ type: 'finish', reason: { kind: 'stop' } }], seen) + await withServices(llm, fakeSessions({ provider: 'deepseek-official', model: 'deepseek-flash' }), async (call) => { + /* The picked repository wins over the cwd. A clean repository stops at + "nothing to describe" — before the model is reached, so the fake stream + stays unused and what the assertion measures is the resolved root. */ + const withRoot = await call('commit-message', { cwd: workspace, repoRoot: picked, sessionId: 'sess-1' }) + assert.equal(withRoot.status, 400, JSON.stringify(withRoot.body)) + assert.match(withRoot.body.error.message, /nothing to describe/) + + /* Without a picked repository the guard stays honest: a cwd outside every + repository is still reported as not-a-repo, never silently guessed. */ + const withoutRoot = await call('commit-message', { cwd: workspace, sessionId: 'sess-1' }) + assert.equal(withoutRoot.status, 409, JSON.stringify(withoutRoot.body)) + assert.equal(withoutRoot.body.error.code, 'not-a-repo') + }) + assert.equal(seen.length, 0, 'a clean repository never reaches the model') + } finally { + rmSync(workspace, { recursive: true, force: true }) + rmSync(picked, { recursive: true, force: true }) + } +}) + test('commit-message: an error finish is a failure with the provider text, not an empty message', async () => { const failure = { code: 'NO_CREDENTIAL', message: 'no API key for provider route "deepseek-official"; store DEEPSEEK_API_KEY' } const llm = fakeLlm([{ type: 'finish', reason: { kind: 'error', failure } }]) diff --git a/tests/smoke.mjs b/tests/smoke.mjs index 6c7f2e8..ce6a54b 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -1592,3 +1592,18 @@ test('block separators paint on border-l2, not the hairline token (v0.13.1 style assert.doesNotMatch(rule, /border-l2/, selector + ' stays on the hairline (not a block)') } }) + +test('the AI commit message carries the picked repository (multi-repo workspace)', () => { + // A workspace may hold several sibling repositories, so the session cwd need + // not be one. Every other request carries `base` ({ cwd, repoRoot }); the AI + // message built its payload from `cwd` alone, and the host — which resolves the + // root from `cwd` whenever `repoRoot` is missing — answered not-a-repo. + const aiRun = client.slice(client.indexOf('const aiRun = async () =>'), client.indexOf('setAiUndo(')) + assert.ok(aiRun.length > 200, 'the AI run block must exist') + assert.match(aiRun, /request\('commit-message', Object\.assign\(/, 'one payload builder, like every other call') + assert.match(aiRun, /props\.base/, 'the picked repository (base) is part of that payload') + // A prop that is never handed down reads as undefined, so the component that + // sends the request must actually receive the repo identity. + const pane = client.slice(client.indexOf('E(ChangesPanel, {'), client.indexOf('onCommit:')) + assert.match(pane, /base: base,/, 'Panel hands the repo identity down to ChangesPanel') +})