Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
40 changes: 40 additions & 0 deletions tests/api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }])
Expand Down
15 changes: 15 additions & 0 deletions tests/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})