spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

diff: a shell command the tool held read-only fetches nothing, as the built-in panel's touch is gatedBrowse files
poteat authored on Sep 18, 2026 parent a92ea1c · commit c568a8cde3e2
6 changed files+74 -2compared with a92ea1c
Mmods/diff/hooks/register.ts
Mmods/diff/hooks/tools/index.ts
Amods/diff/hooks/tools/may-have-written.ts
Mmods/diff/tests/fixtures/index.ts
Amods/diff/tests/fixtures/read-only-answer.ts
Mmods/diff/tests/register.test.ts
@@ -830,8 +830,7 @@
830830 result.isError !== true
831831
832832 const isStale =
833- isPaneOpen &&
834- (isEdit ? hasEdited : result === undefined || result.deny === undefined)
833+ isPaneOpen && (isEdit ? hasEdited : Tools.mayHaveWritten(result))
835834
836835 if (isStale) {
837836 scheduleRefresh(engine)
@@ -1,4 +1,5 @@
11 export * from './editing-tools.js'
2+export * from './may-have-written.js'
23 export * from './shell-tools.js'
34 export * from './todo-tool.js'
45
@@ -1,0 +1,23 @@
1+import type { ResultOf } from 'claude-code'
2+
3+/**
4+ * Whether a shell tool's outcome may have changed the working tree, as the
5+ * built-in gates its refresh: a throw or an answer, unless the engine says
6+ * the tool held the call read-only (`ls`, `git status`); never a deny.
7+ *
8+ * `isReadOnly` is read through a type that may lack it, ahead of the
9+ * declarations that name it; absent, the call counts as a write.
10+ *
11+ * @param result what `next` resolved to, or undefined when it threw
12+ * @returns true when a refresh is due
13+ */
14+export function mayHaveWritten(
15+ result: ResultOf['tool.call'] | undefined,
16+): boolean {
17+ const settled: { deny?: string; isReadOnly?: boolean } | undefined = result
18+
19+ return (
20+ settled === undefined ||
21+ (settled.deny === undefined && settled.isReadOnly !== true)
22+ )
23+}
@@ -38,6 +38,7 @@
3838 export * from './pane-view'
3939 export * from './parse-file-diff'
4040 export * from './poll-word.js'
41+export * from './read-only-answer.js'
4142 export * from './renamed.js'
4243 export * from './repository.js'
4344 export * from './repository-of'
@@ -1,0 +1,13 @@
1+import type { ResultOf } from 'claude-code'
2+
3+/**
4+ * A shell tool's answer as the engine gives it for a command the tool holds
5+ * read-only (`ls`, `git status`): the result, and `isReadOnly` beside it.
6+ *
7+ * Built through Object.assign, since the declarations may not name the
8+ * field yet; the plugin reads it through a type that may lack it too.
9+ */
10+export const READ_ONLY_ANSWER: ResultOf['tool.call'] = Object.assign(
11+ { result: 'listed' },
12+ { isReadOnly: true as const },
13+)
@@ -121,6 +121,41 @@
121121 )
122122 })
123123
124+ test('a shell command the tool held read-only fetches nothing', async ($, on) => {
125+ const world = Fixtures.inRepository(on)
126+
127+ const fetchesSince = (read: number) =>
128+ world.runs.slice(read).filter(run => run.argv.includes('--numstat'))
129+ .length
130+
131+ on('tool.call', ($, e) =>
132+ e.tool === 'Bash' && e.command === 'ls'
133+ ? Fixtures.READ_ONLY_ANSWER
134+ : { result: 'done' },
135+ )
136+
137+ await $.session.start(Fixtures.SESSION)
138+ await $.command.run(Fixtures.DIFF)
139+ await world.clock.advance(Fixtures.SETTLE_MS)
140+
141+ const opened = world.runs.length
142+
143+ await $.tool.call({ tool: 'Bash', command: 'ls' })
144+ await world.clock.advance(Fixtures.SETTLE_MS)
145+
146+ expect(
147+ fetchesSince(opened),
148+ "read-only, the pane open: nothing, as the built-in's touch is gated",
149+ ).toBe(0)
150+
151+ const listed = world.runs.length
152+
153+ await $.tool.call({ tool: 'Bash', command: 'make' })
154+ await world.clock.advance(Fixtures.SETTLE_MS)
155+
156+ expect(fetchesSince(listed), 'a command that may write: one fetch').toBe(1)
157+ })
158+
124159 test('/diff whose probe never answers probes once more', async ($, on) => {
125160 const probes: (readonly string[])[] = []
126161 const clock = Fixtures.startsSession(on)