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: run git when the built-in panel would, never at the session's start (#94594)Browse files
* diff: the session's start asks nothing of the repository; /diff or the first edit pins it when it comes

* diff: git runs when the built-in panel's would: one rev-parse at first need, the tree walked only for an open pane, nothing after a turn or a refused tool

* diff: a shell command that failed or was cut short still refreshes an open pane, an edit that did opens nothing, and a probe /clear overtook pins nothing
Alice T'Poteat authored on Sep 15, 2026 · GitHub committed on Sep 15, 2026 parent aad35ba · commit 7dd06361110d
12 changed files+443 -123compared with aad35ba
Mmods/diff/README.md
Mmods/diff/hooks/git/git-backend-of.ts
Mmods/diff/hooks/git/probes/dirty-paths-of/dirty-paths-of.ts
Mmods/diff/hooks/git/probes/was-dirty-at-start/was-dirty-at-start.ts
Mmods/diff/hooks/git/types/git-deps/git-deps.ts
Mmods/diff/hooks/register.ts
Mmods/diff/tests/backend/backend-of.test.ts
Amods/diff/tests/fixtures/git-word-of.ts
Mmods/diff/tests/fixtures/index.ts
Amods/diff/tests/fixtures/poll-word.ts
Mmods/diff/tests/git.test.ts
Mmods/diff/tests/register.test.ts
@@ -11,11 +11,11 @@
1111 built-in's list keys (`ctrl+up`/`ctrl+down`, `opt+up`/`opt+down`), and
1212 `ctrl+x b` moves the comparison base on, as the built-in's chord does: both
1313 through Buttons that declare the engine's own actions. The pane refreshes
14-as Claude edits, runs shell commands and finishes turns, and while it is
15-open it polls the repository's HEAD so a commit or checkout made elsewhere
16-shows too. The first successful edit of a session opens the pane by itself
17-where the terminal is wide enough (144 columns when the person never chose,
18-110 when they kept it open before; a person who closed it is left alone).
14+as Claude edits and runs shell commands, and while it is open it polls
15+the repository's HEAD so a commit or checkout made elsewhere shows too.
16+The first successful edit of a session opens the pane by itself where the
17+terminal is wide enough (144 columns when the person never chose, 110 when
18+they kept it open before; a person who closed it is left alone).
1919
2020 Under the fullscreen layout a terminal under 110 columns gets the
2121 built-in's line asking for a wider one and nothing opens. Without that
@@ -36,28 +36,37 @@
3636 the session's, and the choice is kept per repository in the plugin's store.
3737 A picker shows one earlier turn's edits instead of the working tree, read
3838 from the session's messages. Files that changed before the session started
39-(by their timestamp, among the paths already dirty at the start), and noise
40-(lockfiles, generated and test files), are listed apart and folded until
41-asked for; a rename lists as git prints it. Outside a git repository
42-`/diff` says so and does nothing else.
39+(by their timestamp, among the paths already dirty when the pane first
40+read the repository), and noise (lockfiles, generated and test files), are
41+listed apart and folded until asked for; a rename lists as git prints it.
42+Outside a git repository `/diff` says so and does nothing else.
4343
44+Git runs when the built-in panel's would: nothing at the session's start;
45+one `git rev-parse`, in the directory the session started in, when `/diff`
46+or the first edit a pane has room to open on first needs the repository
47+(an answer of no repository is kept too, until `/clear` or `/resume`
48+forgets it); and the working tree is read only by a fetch for a pane that
49+is open, after an edit that landed or a shell command that ran. The one
50+read the built-in has no counterpart for is a `git status` at a pane's
51+first fetch, which stands in for the change time the built-in dates a
52+moved file by.
53+
4454 `hooks/register.ts` is the module; everything under `hooks/` is its parts.
4555
4656 ## What it hooks
4757
4858 | event | what the hook does |
4959 | --- | --- |
50-| `session.start` | Binds the engine once, registers `/diff` (a session where another `/diff` is listed leaves the plugin idle), and pins the repository. |
60+| `session.start` | Binds the engine once and registers `/diff` (a session where another `/diff` is listed leaves the plugin idle); asks nothing of the repository, which `/diff` or the first edit pins when it comes. |
5161 | `ui.render` of `PromptHint` | Reads the terminal's width, which decides whether the first edit opens the pane. |
5262 | `ui.render` of `Pane` | Draws the pane: docked, the header, base line, source picker, file list and toggles over the window of hunks; inline, the dialog. |
53-| `command.run` of `diff` | Opens or closes the pane (focused and closing on Escape without the fullscreen layout), says which, and remembers the choice. |
63+| `command.run` of `diff` | Pins the repository when none is, opens or closes the pane (focused and closing on Escape without the fullscreen layout), says which, and remembers the choice. |
5464 | `ui.close` of the pane | Backs out of the dialog's detail view instead of closing; else remembers the person's close as `/diff`'s. |
5565 | `ui.scroll` of the pane | Docked, moves the hunks under the pinned header and list (three rows a wheel tick, a page a page key), or the list when the wheel is over it, and keeps the engine's window still. |
5666 | `ui.focus` in the pane | In the dialog's list, selects the file the ring lands on, re-centres the five rows on it, and lands the ring where that row now sits. |
57-| `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state. |
58-| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After the edit, refreshes an open pane; the session's first successful edit opens it. |
59-| `tool.call` of `Bash`, `PowerShell` | After the command, refreshes an open pane. |
60-| `turn.complete` | Refreshes an open pane. |
67+| `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state, the pinned repository with it. |
68+| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that landed (not refused, not failed), refreshes an open pane; the session's first such edit opens it, pinning the repository then if the terminal has the room. |
69+| `tool.call` of `Bash`, `PowerShell` | After a command that was not refused, failed and interrupted ones too, refreshes an open pane. |
6170 | `prompt.submit` | Adds the armed file's hunks to the prompt's context and disarms. |
6271
6372 ## What it calls on `$`
@@ -15,7 +15,7 @@
1515 *
1616 * Each child runs with `--git-dir`, `--work-tree` and cwd the top, under
1717 * the C locale and the fetch's timeout (the built-in's execPinnedGit); the
18- * paths dirty at the start are read once, first (Probes.dirtyPathsOf).
18+ * first fetch reads the paths dirty then, once (Probes.dirtyPathsOf).
1919 *
2020 * @param host the bound host's runner and probes
2121 * @returns the backend, or null outside a git working tree
@@ -47,15 +47,16 @@
4747 }
4848
4949 const run = runOf(repository)
50- const baseline = await Probes.dirtyPathsOf(run)
5150
52- const depsOf = (): Types.GitDeps => ({
51+ let baseline: Promise<ReadonlySet<string> | null> | null = null
52+
53+ const depsOf = (dirty: ReadonlySet<string> | null): Types.GitDeps => ({
5354 run,
5455 repository,
5556 mtimeOf: host.mtimeOf,
5657 entryKindsOf: host.entryKindsOf,
5758 sessionStartMs: host.sessionStartMsOf(),
58- baseline,
59+ baseline: dirty,
5960 onBranchBase: host.onBranchBase,
6061 })
6162
@@ -63,9 +64,16 @@
6364 repository,
6465 baseModes: GIT_BASE_MODES,
6566 words: GIT_WORDS,
66- fetchDiff: mode => fetchDiff(depsOf(), mode),
67+ fetchDiff: async mode => {
68+ baseline ??= Probes.dirtyPathsOf(run)
69+
70+ return fetchDiff(depsOf(await baseline), mode)
71+ },
6772 fetchFileHunks: (data, file) => fetchFileHunks(run, data, file),
6873 headKeyOf: () =>
69- Probes.headKeyOf({ ...depsOf(), readFile: host.readFile }, repository),
74+ Probes.headKeyOf(
75+ { ...depsOf(null), readFile: host.readFile },
76+ repository,
77+ ),
7078 }
7179 }
@@ -7,9 +7,9 @@
77 * Every path dirty in the working tree right now, tracked changes and
88 * untracked files alike, as `git status -z` lists them; null unread.
99 *
10- * Read once when the backend is pinned, so a fetch can tell a path that
11- * was already dirty when the session began from one that turned up since
12- * (GitDeps `baseline`). Renames are not paired: each side is its own path.
10+ * Read once, by the backend's first fetch (it walks the whole tree), so a
11+ * later fetch can tell a path that was dirty then from one that turned up
12+ * since (GitDeps `baseline`). Renames unpaired: each side is its own path.
1313 *
1414 * @param run runs git against the pinned repository
1515 * @returns the paths, root-relative, or null on a failed or cut listing
@@ -2,7 +2,7 @@
22
33 /**
44 * Whether a path may predate the session at all: it was dirty when the
5- * session began, or nothing says otherwise (no baseline was read).
5+ * baseline was read (the first fetch), or nothing says otherwise (none was).
66 *
77 * A path missing from the baseline turned up since, so its old timestamp
88 * (a rename keeps the file's, a move keeps the source's) does not date it,
@@ -12,7 +12,5 @@
1212 * @param path the root-relative path
1313 * @returns false only when a baseline exists and lacks the path
1414 */
15-export const wasDirtyAtStart = (
16- context: Types.DatingContext,
17- path: string,
18-): boolean => context.deps.baseline?.has(path) ?? true
15+export const wasDirtyAtStart = (context: Types.DatingContext, path: string) =>
16+ context.deps.baseline?.has(path) ?? true
@@ -33,11 +33,11 @@
3333 sessionStartMs: number
3434
3535 /**
36- * The paths dirty when the backend was pinned, at the session's start
37- * (Probes.dirtyPathsOf); null when unlisted, absent where none is kept.
36+ * The paths dirty when the backend first fetched (Probes.dirtyPathsOf),
37+ * read then and kept; null when unlisted, absent where none is kept.
3838 *
39- * A path absent from it turned up during the session (a rename's new
40- * name, a file moved in), whatever timestamp it carries.
39+ * A path absent from it turned up since (a rename's new name, a file
40+ * moved in), whatever timestamp it carries.
4141 */
4242 baseline?: ReadonlySet<string> | null
4343
@@ -29,9 +29,9 @@
2929 * Registers the diff pane: `/diff` once the built-in stands down, the
3030 * pane's drawing and refresh, its opening on Claude's first edit, the ask.
3131 *
32- * `session.start` registers `/diff`, binds the host every pinned backend
33- * reads through (currentOf: the latest start's, the engine only before
34- * bind()), and pins the backend (backendOf), asked again on `/diff`.
32+ * Git runs when the built-in's would: `session.start` binds the host and
33+ * registers `/diff`; `/diff` or the first edit with room pins the backend
34+ * where the session started, until `/clear`; an open pane alone fetches.
3535 *
3636 * @param on the engine's registrar
3737 */
@@ -56,6 +56,7 @@
5656 const bodyLoads = new Map<string, Promise<Git.FileHunks | null>>()
5757
5858 const polled = { toplevel: '', headKey: '' }
59+ const pin = { cwd: '', isEmpty: false, epoch: 0 }
5960
6061 let model: PaneState.PaneModel = PaneState.INITIAL_MODEL
6162
@@ -65,7 +66,11 @@
6566 const currentOf = (engine: Host): Host => host ?? engine
6667
6768 const backendHostOf = (engine: Host): Backend.BackendHost => ({
68- run: (argv, init) => currentOf(engine).run(argv, init),
69+ run: (argv, init) =>
70+ currentOf(engine).run(
71+ argv,
72+ pin.cwd === '' ? init : { cwd: pin.cwd, ...init },
73+ ),
6974 readFile: path => currentOf(engine).readFile(path),
7075 mtimeOf: path => mtimeOf(currentOf(engine))(path),
7176 entryKindsOf: dir => entryKindsOf(currentOf(engine))(dir),
@@ -95,7 +100,7 @@
95100 })
96101
97102 function pinBackend(engine: Host): Promise<boolean> {
98- if (backend) {
103+ if (backend || pin.isEmpty) {
99104 return Promise.resolve(true)
100105 }
101106
@@ -109,6 +114,7 @@
109114 async function probeBackend(engine: Host): Promise<boolean> {
110115 const asked = { isAnswered: true }
111116 const probeHost = backendHostOf(engine)
117+ const { epoch } = pin
112118
113119 const probed = await Backend.backendOf(
114120 {
@@ -124,7 +130,12 @@
124130 Backend.INSTALLED_BACKEND_PROBES,
125131 )
126132
133+ if (epoch !== pin.epoch) {
134+ return false
135+ }
136+
127137 backend ??= probed
138+ pin.isEmpty = backend === null && asked.isAnswered
128139
129140 if (!probed || backend !== probed) {
130141 return asked.isAnswered || backend !== null
@@ -148,6 +159,16 @@
148159 return true
149160 }
150161
162+ function unpin() {
163+ backend = null
164+ pin.isEmpty = false
165+ pin.epoch += 1
166+ polled.toplevel = ''
167+ polled.headKey = ''
168+ timers.get('poll')?.cancel()
169+ timers.delete('poll')
170+ }
171+
151172 function dialogPane(): PaneOpenArgs {
152173 return {
153174 id: Names.PANE_ID,
@@ -423,26 +444,28 @@
423444 }
424445
425446 const preference = await engine.storeGet(Names.STORE_OPEN_KEY)
426-
427- await pinBackend(engine)
428-
429447 const isKeptOpen = preference === true
430448
431449 const floor = isKeptOpen
432450 ? Limits.OPEN_MIN_COLUMNS
433451 : Limits.AUTO_OPEN_MIN_COLUMNS
434452
435- const isEligible =
453+ const hasRoom =
436454 preference !== false &&
437455 model.isFullscreen !== false &&
438456 columns !== null &&
439- columns >= floor &&
440- backend !== null
457+ columns >= floor
441458
442- if (!isEligible || isTaken()) {
459+ if (!hasRoom || isTaken()) {
443460 return
444461 }
445462
463+ await pinBackend(engine)
464+
465+ if (!backend || isTaken()) {
466+ return
467+ }
468+
446469 hasAutoOpened = true
447470 await openPane(engine, 'auto_open')
448471 }
@@ -551,8 +574,9 @@
551574 redraw(engine)
552575 }
553576
554- async function bind(engine: Host): Promise<void> {
577+ async function bind(engine: Host, cwd: string): Promise<void> {
555578 sessionStartMs = await engine.now()
579+ pin.cwd = cwd
556580
557581 try {
558582 await engine.registerCommand(COMMAND_SPEC)
@@ -563,35 +587,34 @@
563587 if (!Names.BUILTIN_HOLDS_PATTERN.test(reason)) {
564588 engine.uiLog(Names.registerFailedTextOf(Views.sanitizeName(reason)))
565589 }
566-
567- return
568590 }
569-
570- await pinBackend(engine)
571591 }
572592
573593 on('session.start', async ($, e, next) => {
574- await bind({
575- now: () => $.clock.now(),
576- after: (ms, fn) => $.clock.after(ms, fn),
577- every: (ms, fn) => $.clock.every(ms, fn),
578- run: (argv, init) => $.process.run(argv, init),
579- stat: path => $.fs.stat(path),
580- listDir: path => $.fs.list(path),
581- readFile: path => $.fs.read(path),
582- storeGet: key => $.store.get(key),
583- storeSet: (key, value) => $.store.set(key, value),
584- messages: () => $.session.messages(),
585- invalidate: () => $.ui.invalidate('ui.render'),
586- status: text => $.ui.status(text),
587- uiLog: text => $.ui.log(text),
588- openPane: pane => $.ui.open(pane),
589- closePane: pane => $.ui.close(pane),
590- registerCommand: spec => $.command.register(spec),
591- sessionId: () => $.session.id(),
592- mark: entry => $.telemetry.mark(entry),
593- log: entry => $.telemetry.log(entry),
594- })
594+ await bind(
595+ {
596+ now: () => $.clock.now(),
597+ after: (ms, fn) => $.clock.after(ms, fn),
598+ every: (ms, fn) => $.clock.every(ms, fn),
599+ run: (argv, init) => $.process.run(argv, init),
600+ stat: path => $.fs.stat(path),
601+ listDir: path => $.fs.list(path),
602+ readFile: path => $.fs.read(path),
603+ storeGet: key => $.store.get(key),
604+ storeSet: (key, value) => $.store.set(key, value),
605+ messages: () => $.session.messages(),
606+ invalidate: () => $.ui.invalidate('ui.render'),
607+ status: text => $.ui.status(text),
608+ uiLog: text => $.ui.log(text),
609+ openPane: pane => $.ui.open(pane),
610+ closePane: pane => $.ui.close(pane),
611+ registerCommand: spec => $.command.register(spec),
612+ sessionId: () => $.session.id(),
613+ mark: entry => $.telemetry.mark(entry),
614+ log: entry => $.telemetry.log(entry),
615+ },
616+ e.cwd,
617+ )
595618
596619 return next(e)
597620 })
@@ -773,6 +796,7 @@
773796 await closePane(host).catch(() => undefined)
774797 }
775798
799+ unpin()
776800 hasAutoOpened = false
777801 bodyStamp = null
778802 bodyBase = null
@@ -784,46 +808,50 @@
784808 return result
785809 })
786810
787- on('tool.call', { tool: [...Tools.EDITING_TOOLS] }, async ($, e, next) => {
788- let result: ResultOf['tool.call'] | undefined
811+ function afterTool(
812+ engine: Host,
813+ tool: string,
814+ result: ResultOf['tool.call'] | undefined,
815+ ) {
816+ const isEdit = Tools.EDITING_TOOLS.some(name => name === tool)
789817
790- try {
791- result = await next(e)
818+ const hasEdited =
819+ isEdit &&
820+ result !== undefined &&
821+ result.deny === undefined &&
822+ result.isError !== true
792823
793- return result
794- } finally {
795- if (host) {
796- if (isPaneOpen) {
797- scheduleRefresh(host)
798- }
824+ const isStale =
825+ isPaneOpen &&
826+ (isEdit ? hasEdited : result === undefined || result.deny === undefined)
799827
800- const isEditDone = result !== undefined && !('deny' in result)
801-
802- if (isEditDone) {
803- void openOnFirstEdit(host).catch(() => undefined)
804- }
805- }
828+ if (isStale) {
829+ scheduleRefresh(engine)
806830 }
807- })
808831
809- on('tool.call', { tool: [...Tools.SHELL_TOOLS] }, async ($, e, next) => {
810- try {
811- return await next(e)
812- } finally {
813- if (host && isPaneOpen) {
814- scheduleRefresh(host)
815- }
832+ if (hasEdited) {
833+ void openOnFirstEdit(engine).catch(() => undefined)
816834 }
817- })
835+ }
818836
819- on('turn.complete', ($, e, next) => {
820- if (host && isPaneOpen) {
821- scheduleRefresh(host)
822- }
837+ on(
838+ 'tool.call',
839+ { tool: [...Tools.EDITING_TOOLS, ...Tools.SHELL_TOOLS] },
840+ async ($, e, next) => {
841+ let result: ResultOf['tool.call'] | undefined
823842
824- return next(e)
825- })
843+ try {
844+ result = await next(e)
826845
846+ return result
847+ } finally {
848+ if (host) {
849+ afterTool(host, e.tool, result)
850+ }
851+ }
852+ },
853+ )
854+
827855 on('prompt.submit', async ($, e, next) => {
828856 const asked = armed
829857
@@ -13,10 +13,10 @@
1313 expect((await Backend.backendOf(inGit.host, []))?.words.lister).toBe('git')
1414 expect(await Backend.backendOf(outside.host, [])).toBeNull()
1515
16- expect([inGit.programs, outside.programs]).toEqual([
17- ['git', 'git'],
18- ['git'],
19- ])
16+ expect(
17+ [inGit.programs, outside.programs],
18+ 'pinning spawns the one rev-parse and walks nothing',
19+ ).toEqual([['git'], ['git']])
2020 })
2121
2222 test('a declining probe leaves a git working tree to git', async () => {
@@ -27,7 +27,7 @@
2727 expect(backend?.words.lister).toBe('git')
2828 expect(backend?.baseModes).toEqual(['session', 'uncommitted', 'branch'])
2929 expect(declining.asked).toEqual([inGit.host])
30- expect(inGit.programs).toEqual(['git', 'git'])
30+ expect(inGit.programs).toEqual(['git'])
3131 })
3232
3333 test('a probe that answers pins before git is asked', async () => {
@@ -1,0 +1,36 @@
1+/**
2+ * One git invocation named by what it asks: its subcommand, and its kind.
3+ *
4+ * For a `diff` what kind (`--shortstat`, `--numstat`, or the path its body
5+ * is asked for), for a `rev-parse` whether it finds the repository
6+ * (`--show-toplevel`) or reads `HEAD` (the poll's fallback where no file
7+ * answers), so a test reads a session's spawns as the built-in's list.
8+ *
9+ * @param argv the invocation, program first, any pinned lead after it
10+ * @returns `rev-parse --show-toplevel`, `status`, `diff --numstat`, ...
11+ */
12+export function gitWordOf(argv: readonly string[]) {
13+ const [subcommand = '?'] = argv.filter(
14+ (word, at) => at > 0 && !word.startsWith('-') && argv[at - 1] !== '-c',
15+ )
16+
17+ const dashes = argv.indexOf('--')
18+
19+ if (subcommand === 'rev-parse') {
20+ return argv.includes('--show-toplevel')
21+ ? 'rev-parse --show-toplevel'
22+ : 'rev-parse HEAD'
23+ }
24+
25+ if (subcommand !== 'diff') {
26+ return subcommand
27+ }
28+
29+ if (dashes !== -1) {
30+ return `diff -- ${argv.slice(dashes + 1).join(' ')}`
31+ }
32+
33+ const stat = argv.find(word => word === '--shortstat' || word === '--numstat')
34+
35+ return stat ? `diff ${stat}` : 'diff'
36+}
@@ -15,6 +15,7 @@
1515 export * from './fetch-diff'
1616 export * from './git-hung.js'
1717 export * from './git-in.js'
18+export * from './git-word-of.js'
1819 export * from './head-key-of'
1920 export * from './header-totals-of'
2021 export * from './hint.js'
@@ -35,6 +36,7 @@
3536 export * from './pane.js'
3637 export * from './pane-view'
3738 export * from './parse-file-diff'
39+export * from './poll-word.js'
3840 export * from './renamed.js'
3941 export * from './repository.js'
4042 export * from './repository-of'
@@ -1,0 +1,7 @@
1+/**
2+ * The open pane's HEAD poll as gitWordOf names it where no file answers.
3+ *
4+ * On disk the poll stats `.git/HEAD` and spawns nothing, so a test that
5+ * lists the session's spawns as the built-in's lets this word by.
6+ */
7+export const POLL_WORD = 'rev-parse HEAD'
@@ -20,7 +20,12 @@
2020 const diffs = pinned.filter(run => run.argv.includes('diff'))
2121
2222 expect(discovery?.argv).toContain('--show-toplevel')
23- expect(discovery?.init?.cwd).toBeUndefined()
23+
24+ expect(
25+ discovery?.init?.cwd,
26+ "found where the session started, wherever Claude's shell has gone",
27+ ).toBe('/main/wt')
28+
2429 expect(diffs.length).toBeGreaterThanOrEqual(3)
2530
2631 for (const run of world.runs) {
@@ -38,6 +43,34 @@
3843 expect(world.runs.some(run => run.argv.includes('config'))).toBe(false)
3944 })
4045
46+ test('the working tree is walked once, by the first fetch', async ($, on) => {
47+ const world = Fixtures.inRepository(on)
48+
49+ const walks = () =>
50+ world.runs.filter(run => Fixtures.gitWordOf(run.argv) === 'status')
51+
52+ const fetches = () =>
53+ world.runs.filter(
54+ run => Fixtures.gitWordOf(run.argv) === 'diff --numstat',
55+ )
56+
57+ on('tool.call', () => ({ result: 'ran' }))
58+
59+ await $.session.start(Fixtures.SESSION)
60+ await $.command.run(Fixtures.DIFF)
61+ await world.clock.advance(Fixtures.SETTLE_MS)
62+
63+ expect(walks(), 'the first fetch walked').toHaveLength(1)
64+ expect(fetches()).toHaveLength(1)
65+
66+ await $.tool.call({ tool: 'Bash', command: 'make' })
67+ await world.clock.advance(Fixtures.SETTLE_MS)
68+
69+ expect(fetches(), 'the command fetched again').toHaveLength(2)
70+ expect(walks(), 'and read the walk it had').toHaveLength(1)
71+ expect(walks()[0]?.init?.cwd, 'pinned like every child').toBe('/work')
72+ })
73+
4174 test('a file moved in since the start is session work', async ($, on) => {
4275 const clock = Fixtures.startsSession(on, Fixtures.SETTLE_MS)
4376
@@ -1,4 +1,4 @@
1-import type { Args } from 'claude-code'
1+import type { Args, ResultOf } from 'claude-code'
22 import { describe, expect, mock, test, tier } from 'claude-code/testing'
33
44 import Limits from '../hooks/limits'
@@ -8,7 +8,120 @@
88 tier('builtin')
99
1010 describe('register', () => {
11- test('/diff at boot joins the boot probe, then asks again', async ($, on) => {
11+ test('the start asks nothing of git and registers /diff', async ($, on) => {
12+ const world = Fixtures.inRepository(on)
13+
14+ await $.session.start(Fixtures.SESSION)
15+
16+ expect(world.runs, "no git, as the built-in's start runs none").toEqual([])
17+
18+ expect(await $.command.run(Fixtures.DIFF), '/diff was registered').toEqual({
19+ text: 'Diff panel shown',
20+ })
21+ })
22+
23+ test("git runs at the built-in's moments and no others", async ($, on) => {
24+ const world = Fixtures.inRepository(on)
25+
26+ let read = 0
27+
28+ function spawnedSince(): string[] {
29+ const words = world.runs
30+ .slice(read)
31+ .map(run => Fixtures.gitWordOf(run.argv))
32+ .filter(word => word !== Fixtures.POLL_WORD)
33+
34+ read = world.runs.length
35+
36+ return words
37+ }
38+
39+ const edit = () =>
40+ $.tool.call({
41+ tool: 'Edit',
42+ file_path: '/work/app.ts',
43+ old_string: '1',
44+ new_string: '2',
45+ })
46+
47+ on('tool.call', () => ({ result: 'done' }))
48+ on('turn.complete', ($, e) => ({ text: e.answer }))
49+ on('command.run', { command: 'clear' }, () => ({}))
50+
51+ await $.session.start(Fixtures.SESSION)
52+ await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
53+ await world.clock.advance(Fixtures.SETTLE_MS)
54+
55+ expect(spawnedSince(), 'the start and the footer: nothing').toEqual([])
56+
57+ await edit()
58+ await world.clock.advance(Fixtures.SETTLE_MS)
59+
60+ expect(
61+ spawnedSince(),
62+ 'an edit with no room for a pane: nothing, the width is read first',
63+ ).toEqual([])
64+
65+ await $.command.run(Fixtures.DIFF)
66+ await world.clock.advance(Fixtures.SETTLE_MS)
67+
68+ expect(spawnedSince(), '/diff: found, then one fetch for the pane').toEqual(
69+ [
70+ 'rev-parse --show-toplevel',
71+ 'status',
72+ 'diff --shortstat',
73+ 'diff --numstat',
74+ 'ls-files',
75+ 'diff -- app.ts',
76+ ],
77+ )
78+
79+ await edit()
80+ await world.clock.advance(Fixtures.SETTLE_MS)
81+
82+ expect(spawnedSince(), 'an edit, the pane open: one fetch').toEqual([
83+ 'diff --shortstat',
84+ 'diff --numstat',
85+ 'ls-files',
86+ 'diff -- app.ts',
87+ ])
88+
89+ await $.tool.call({ tool: 'Bash', command: 'make' })
90+ await world.clock.advance(Fixtures.SETTLE_MS)
91+
92+ expect(spawnedSince(), 'a shell command, the pane open: one fetch').toEqual(
93+ ['diff --shortstat', 'diff --numstat', 'ls-files', 'diff -- app.ts'],
94+ )
95+
96+ await $.turn.complete({
97+ answer: 'done',
98+ durationMs: 1,
99+ isAborted: false,
100+ turnId: 't1',
101+ reason: 'answer',
102+ })
103+
104+ await world.clock.advance(Fixtures.SETTLE_MS)
105+
106+ expect(spawnedSince(), "a turn's end: nothing").toEqual([])
107+
108+ await $.command.run(Fixtures.DIFF)
109+ await edit()
110+ await $.tool.call({ tool: 'Bash', command: 'make' })
111+ await world.clock.advance(Fixtures.SETTLE_MS)
112+
113+ expect(spawnedSince(), 'the pane closed by hand: nothing').toEqual([])
114+
115+ await $.command.run(Fixtures.CLEAR)
116+ await $.command.run(Fixtures.DIFF)
117+ await world.clock.advance(Fixtures.SETTLE_MS)
118+
119+ expect(spawnedSince()[0], '/clear forgot the repository').toBe(
120+ 'rev-parse --show-toplevel',
121+ )
122+ })
123+
124+ test('/diff whose probe never answers probes once more', async ($, on) => {
12125 const probes: (readonly string[])[] = []
13126 const clock = Fixtures.startsSession(on)
14127
@@ -24,26 +137,42 @@
24137 return { value: Fixtures.NOT_A_REPOSITORY }
25138 })
26139
27- const booting = $.session.start(Fixtures.SESSION)
140+ await $.session.start(Fixtures.SESSION)
28141
29- await clock.settle()
30-
31142 const ran = $.command.run(Fixtures.DIFF)
32143
33144 await clock.settle()
34145
35- expect(probes, 'the boot probe, which /diff joined').toHaveLength(1)
146+ expect(probes, "this /diff's probe, unanswered yet").toHaveLength(1)
36147
37148 await clock.advance(Limits.GIT_TIMEOUT_MS)
38- await booting
39149
40150 expect(await ran).toEqual({
41151 text: expect.stringContaining("isn't in a git repository"),
42152 })
43153
44- expect(probes, 'then one more of its own').toHaveLength(2)
154+ expect(probes, 'then one more, which answered').toHaveLength(2)
45155 })
46156
157+ test('two /diff typed together probe the repository once', async ($, on) => {
158+ const world = Fixtures.inRepository(on)
159+
160+ await $.session.start(Fixtures.SESSION)
161+
162+ const [first, second] = await Promise.all([
163+ $.command.run(Fixtures.DIFF),
164+ $.command.run(Fixtures.DIFF),
165+ ])
166+
167+ const probes = world.runs.filter(run =>
168+ run.argv.includes('--show-toplevel'),
169+ )
170+
171+ expect(first.text).toMatch(/^Diff panel (shown|hidden)$/)
172+ expect(second.text).toMatch(/^Diff panel (shown|hidden)$/)
173+ expect(probes, 'the second joined the probe in flight').toHaveLength(1)
174+ })
175+
47176 test('outside a repository /diff says so, opens nothing', async ($, on) => {
48177 const opened: string[] = []
49178
@@ -77,12 +206,20 @@
77206
78207 test('when the built-in holds /diff, the mod stands down', async ($, on) => {
79208 const logged: string[] = []
209+ const runs: Args<'process.run'>[] = []
80210
81211 mock.clock(on)
82212 on('session.start', ($, e) => ({ cwd: e.cwd }))
83213 on('command.register', () => ({ deny: Fixtures.BUILTIN_HOLDS }))
84214 on('command.run', () => ({ text: 'the built-in /diff ran' }))
215+ on('tool.call', () => ({ result: 'edited' }))
85216
217+ on('process.run', ($, e) => {
218+ runs.push(e)
219+
220+ return { value: Fixtures.gitIn(e.argv) }
221+ })
222+
86223 on('ui.log', ($, e) => {
87224 logged.push(e.text)
88225
@@ -95,7 +232,15 @@
95232 text: 'the built-in /diff ran',
96233 })
97234
235+ await $.tool.call({
236+ tool: 'Edit',
237+ file_path: '/work/app.ts',
238+ old_string: '1',
239+ new_string: '2',
240+ })
241+
98242 expect(logged).toEqual([])
243+ expect(runs, 'idle: no git for /diff or the edit').toEqual([])
99244 })
100245
101246 test('a refusal the built-in did not cause is said aloud', async ($, on) => {
@@ -127,10 +272,16 @@
127272
128273 await $.session.start(Fixtures.SESSION)
129274
275+ expect(world.runs, 'the start ran nothing').toEqual([])
276+
130277 expect(await $.command.run(Fixtures.DIFF)).toEqual({
131278 text: 'Diff panel shown',
132279 })
133280
281+ expect(world.runs[0]?.argv, '/diff found the repository first').toContain(
282+ '--show-toplevel',
283+ )
284+
134285 expect(world.opened).toEqual([
135286 { id: 'diff', title: 'Diff', holdToasts: true },
136287 ])
@@ -197,6 +348,42 @@
197348 expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
198349 })
199350
351+ test('an edit that failed or was refused opens nothing', async ($, on) => {
352+ const world = Fixtures.inRepository(on)
353+
354+ const answers: ResultOf['tool.call'][] = [
355+ { isError: true, result: 'no such text' },
356+ { deny: 'not allowed' },
357+ ]
358+
359+ const edit = () =>
360+ $.tool.call({
361+ tool: 'Edit',
362+ file_path: '/work/app.ts',
363+ old_string: '1',
364+ new_string: '2',
365+ })
366+
367+ on('tool.call', () => answers.shift() ?? { result: 'edited' })
368+
369+ await $.session.start(Fixtures.SESSION)
370+ await $.ui.render(Fixtures.HINT)
371+ await edit()
372+ await edit()
373+ await world.clock.advance(Fixtures.SETTLE_MS)
374+
375+ expect(world.opened, 'neither edit landed').toEqual([])
376+ expect(world.runs, 'so nothing asked after the repository').toEqual([])
377+
378+ await edit()
379+ await world.clock.advance(Fixtures.SETTLE_MS)
380+
381+ expect(
382+ world.opened.map(pane => pane.id),
383+ 'the third did',
384+ ).toEqual(['diff'])
385+ })
386+
200387 test('/clear closes the pane it finds open', async ($, on) => {
201388 const world = Fixtures.inRepository(on)
202389
@@ -290,19 +477,29 @@
290477 ).toContain('uncommitted (vs HEAD)')
291478 })
292479
293- test('a repository made later is pinned once, on /diff', async ($, on) => {
480+ test('no repository is an answer kept until /clear', async ($, on) => {
294481 const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } =
295482 Fixtures.oneSecret()
296483
297484 const script: Record<string, string> = notYet
298485 const world = Fixtures.inRepository(on, script)
299486
487+ const probesOf = () =>
488+ world.runs.filter(run => run.argv.includes('--show-toplevel')).length
489+
490+ on('command.run', { command: 'clear' }, () => ({}))
491+
300492 await $.session.start(Fixtures.WORKTREE_SESSION)
301493
302494 const before = await $.command.run(Fixtures.DIFF)
303495
304496 script['rev-parse --path-format=absolute'] = worktree
305497
498+ const again = await $.command.run(Fixtures.DIFF)
499+ const probesKept = probesOf()
500+
501+ await $.command.run(Fixtures.CLEAR)
502+
306503 const after = await $.command.run(Fixtures.DIFF)
307504
308505 await world.clock.advance(Fixtures.SETTLE_MS)
@@ -315,14 +512,16 @@
315512 await world.clock.advance(Fixtures.SETTLE_MS)
316513 await third
317514
318- const probes = world.runs.filter(run =>
319- run.argv.includes('--show-toplevel'),
515+ expect(before.text).toContain("isn't in a git repository")
516+
517+ expect(again.text, "kept, as the built-in's answer is").toContain(
518+ "isn't in a git repository",
320519 )
321520
322- expect(before.text).toContain("isn't in a git repository")
323- expect(after.text).toBe('Diff panel shown')
521+ expect(probesKept, 'one probe answered both').toBe(1)
522+ expect(after.text, '/clear forgot it').toBe('Diff panel shown')
324523 expect(world.opened[0]?.id).toBe('diff')
325- expect(probes).toHaveLength(3)
524+ expect(probesOf(), 'then one more, pinned from there on').toBe(2)
326525
327526 expect(
328527 world.runs.some(run => run.argv.includes('--git-dir=/else/.git')),
@@ -380,7 +579,7 @@
380579 const probes = runs.filter(run => run.argv.includes('--show-toplevel'))
381580
382581 expect(text).toContain("isn't in a git repository")
383- expect(probes, "the boot's, then this /diff's; no retry").toHaveLength(2)
582+ expect(probes, "this /diff's alone; no retry").toHaveLength(1)
384583 })
385584
386585 test('/diff on a narrow terminal: the resize line', async ($, on) => {