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
4 changes: 1 addition & 3 deletions mods/diff/hooks/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,9 +927,7 @@ export function register(on: On) {
result.deny === undefined &&
result.isError !== true

const hasLanded = isEdit
? hasEdited
: result === undefined || result.deny === undefined
const hasLanded = isEdit ? hasEdited : Tools.mayHaveWritten(result)

if (hasLanded) {
landed += 1
Expand Down
1 change: 1 addition & 0 deletions mods/diff/hooks/tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './editing-tools.js'
export * from './may-have-written.js'
export * from './shell-tools.js'
export * from './todo-tool.js'

Expand Down
23 changes: 23 additions & 0 deletions mods/diff/hooks/tools/may-have-written.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ResultOf } from 'claude-code'

/**
* Whether a shell tool's outcome may have changed the working tree, as the
* built-in gates its refresh: a throw or an answer, unless the engine says
* the tool held the call read-only (`ls`, `git status`); never a deny.
*
* `isReadOnly` is read through a type that may lack it, ahead of the
* declarations that name it; absent, the call counts as a write.
*
* @param result what `next` resolved to, or undefined when it threw
* @returns true when a refresh is due
*/
export function mayHaveWritten(
result: ResultOf['tool.call'] | undefined,
): boolean {
const settled: { deny?: string; isReadOnly?: boolean } | undefined = result

return (
settled === undefined ||
(settled.deny === undefined && settled.isReadOnly !== true)
)
}
1 change: 1 addition & 0 deletions mods/diff/tests/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export * from './pane.js'
export * from './pane-view'
export * from './parse-file-diff'
export * from './poll-word.js'
export * from './read-only-answer.js'
export * from './renamed.js'
export * from './repository.js'
export * from './repository-of'
Expand Down
13 changes: 13 additions & 0 deletions mods/diff/tests/fixtures/read-only-answer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ResultOf } from 'claude-code'

/**
* A shell tool's answer as the engine gives it for a command the tool holds
* read-only (`ls`, `git status`): the result, and `isReadOnly` beside it.
*
* Built through Object.assign, since the declarations may not name the
* field yet; the plugin reads it through a type that may lack it too.
*/
export const READ_ONLY_ANSWER: ResultOf['tool.call'] = Object.assign(
{ result: 'listed' },
{ isReadOnly: true as const },
)
35 changes: 35 additions & 0 deletions mods/diff/tests/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,41 @@ describe('register', () => {
)
})

test('a shell command the tool held read-only fetches nothing', async ($, on) => {
const world = Fixtures.inRepository(on)

const fetchesSince = (read: number) =>
world.runs.slice(read).filter(run => run.argv.includes('--numstat'))
.length

on('tool.call', ($, e) =>
e.tool === 'Bash' && e.command === 'ls'
? Fixtures.READ_ONLY_ANSWER
: { result: 'done' },
)

await $.session.start(Fixtures.SESSION)
await $.command.run(Fixtures.DIFF)
await world.clock.advance(Fixtures.SETTLE_MS)

const opened = world.runs.length

await $.tool.call({ tool: 'Bash', command: 'ls' })
await world.clock.advance(Fixtures.SETTLE_MS)

expect(
fetchesSince(opened),
"read-only, the pane open: nothing, as the built-in's touch is gated",
).toBe(0)

const listed = world.runs.length

await $.tool.call({ tool: 'Bash', command: 'make' })
await world.clock.advance(Fixtures.SETTLE_MS)

expect(fetchesSince(listed), 'a command that may write: one fetch').toBe(1)
})

test('/diff whose probe never answers probes once more', async ($, on) => {
const probes: (readonly string[])[] = []
const clock = Fixtures.startsSession(on)
Expand Down
Loading