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
| @@ -11,11 +11,11 @@ | ||
| 11 | 11 | built-in's list keys (`ctrl+up`/`ctrl+down`, `opt+up`/`opt+down`), and |
| 12 | 12 | `ctrl+x b` moves the comparison base on, as the built-in's chord does: both |
| 13 | 13 | 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). | |
| 19 | 19 | |
| 20 | 20 | Under the fullscreen layout a terminal under 110 columns gets the |
| 21 | 21 | built-in's line asking for a wider one and nothing opens. Without that |
| @@ -36,11 +36,21 @@ | ||
| 36 | 36 | the session's, and the choice is kept per repository in the plugin's store. |
| 37 | 37 | A picker shows one earlier turn's edits instead of the working tree, read |
| 38 | 38 | 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. | |
| 43 | 43 | |
| 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 or a shell command that was not refused. 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 | + | |
| 44 | 54 | `hooks/register.ts` is the module; everything under `hooks/` is its parts. |
| 45 | 55 | |
| 46 | 56 | ## What it hooks |
| @@ -50,14 +60,13 @@ | ||
| 50 | 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. | |
| 51 | 61 | | `ui.render` of `PromptHint` | Reads the terminal's width, which decides whether the first edit opens the pane. | |
| 52 | 62 | | `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. | | |
| 54 | 64 | | `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. | |
| 55 | 65 | | `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. | |
| 56 | 66 | | `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 was not refused, refreshes an open pane; the session's first successful 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, refreshes an open pane. | | |
| 61 | 70 | | `prompt.submit` | Adds the armed file's hunks to the prompt's context and disarms. | |
| 62 | 71 | |
| 63 | 72 | ## What it calls on `$` |
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | * |
| 16 | 16 | * Each child runs with `--git-dir`, `--work-tree` and cwd the top, under |
| 17 | 17 | * 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). | |
| 19 | 19 | * |
| 20 | 20 | * @param host the bound host's runner and probes |
| 21 | 21 | * @returns the backend, or null outside a git working tree |
| @@ -47,15 +47,16 @@ | ||
| 47 | 47 | } |
| 48 | 48 | |
| 49 | 49 | const run = runOf(repository) |
| 50 | - const baseline = await Probes.dirtyPathsOf(run) | |
| 51 | 50 | |
| 52 | - const depsOf = (): Types.GitDeps => ({ | |
| 51 | + let baseline: Promise<ReadonlySet<string> | null> | null = null | |
| 52 | + | |
| 53 | + const depsOf = (dirty: ReadonlySet<string> | null): Types.GitDeps => ({ | |
| 53 | 54 | run, |
| 54 | 55 | repository, |
| 55 | 56 | mtimeOf: host.mtimeOf, |
| 56 | 57 | entryKindsOf: host.entryKindsOf, |
| 57 | 58 | sessionStartMs: host.sessionStartMsOf(), |
| 58 | - baseline, | |
| 59 | + baseline: dirty, | |
| 59 | 60 | onBranchBase: host.onBranchBase, |
| 60 | 61 | }) |
| 61 | 62 | |
| @@ -63,9 +64,16 @@ | ||
| 63 | 64 | repository, |
| 64 | 65 | baseModes: GIT_BASE_MODES, |
| 65 | 66 | 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 | + }, | |
| 67 | 72 | fetchFileHunks: (data, file) => fetchFileHunks(run, data, file), |
| 68 | 73 | headKeyOf: () => |
| 69 | - Probes.headKeyOf({ ...depsOf(), readFile: host.readFile }, repository), | |
| 74 | + Probes.headKeyOf( | |
| 75 | + { ...depsOf(null), readFile: host.readFile }, | |
| 76 | + repository, | |
| 77 | + ), | |
| 70 | 78 | } |
| 71 | 79 | } |
| @@ -7,9 +7,9 @@ | ||
| 7 | 7 | * Every path dirty in the working tree right now, tracked changes and |
| 8 | 8 | * untracked files alike, as `git status -z` lists them; null unread. |
| 9 | 9 | * |
| 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. | |
| 13 | 13 | * |
| 14 | 14 | * @param run runs git against the pinned repository |
| 15 | 15 | * @returns the paths, root-relative, or null on a failed or cut listing |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | /** |
| 4 | 4 | * 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). | |
| 6 | 6 | * |
| 7 | 7 | * A path missing from the baseline turned up since, so its old timestamp |
| 8 | 8 | * (a rename keeps the file's, a move keeps the source's) does not date it, |
| @@ -12,7 +12,5 @@ | ||
| 12 | 12 | * @param path the root-relative path |
| 13 | 13 | * @returns false only when a baseline exists and lacks the path |
| 14 | 14 | */ |
| 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 @@ | ||
| 33 | 33 | sessionStartMs: number |
| 34 | 34 | |
| 35 | 35 | /** |
| 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. | |
| 38 | 38 | * |
| 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. | |
| 41 | 41 | */ |
| 42 | 42 | baseline?: ReadonlySet<string> | null |
| 43 | 43 |
| @@ -1,10 +1,4 @@ | ||
| 1 | -import type { | |
| 2 | - On, | |
| 3 | - PaneOpenArgs, | |
| 4 | - ResultOf, | |
| 5 | - SessionMessage, | |
| 6 | - Timer, | |
| 7 | -} from 'claude-code' | |
| 1 | +import type { On, PaneOpenArgs, SessionMessage, Timer } from 'claude-code' | |
| 8 | 2 | |
| 9 | 3 | import Ask from './ask' |
| 10 | 4 | import Backend from './backend' |
| @@ -29,9 +23,9 @@ | ||
| 29 | 23 | * Registers the diff pane: `/diff` once the built-in stands down, the |
| 30 | 24 | * pane's drawing and refresh, its opening on Claude's first edit, the ask. |
| 31 | 25 | * |
| 32 | - * `session.start` registers `/diff` and binds the host (currentOf: the | |
| 33 | - * latest start's) and asks nothing of the repository; `/diff` or the | |
| 34 | - * first edit pins the backend (backendOf) when it first needs it. | |
| 26 | + * Git runs when the built-in's would: `session.start` binds the host and | |
| 27 | + * registers `/diff`; `/diff` or the first edit with room pins the backend | |
| 28 | + * where the session started, until `/clear`; an open pane alone fetches. | |
| 35 | 29 | * |
| 36 | 30 | * @param on the engine's registrar |
| 37 | 31 | */ |
| @@ -56,6 +50,7 @@ | ||
| 56 | 50 | const bodyLoads = new Map<string, Promise<Git.FileHunks | null>>() |
| 57 | 51 | |
| 58 | 52 | const polled = { toplevel: '', headKey: '' } |
| 53 | + const pin = { cwd: '', isEmpty: false } | |
| 59 | 54 | |
| 60 | 55 | let model: PaneState.PaneModel = PaneState.INITIAL_MODEL |
| 61 | 56 | |
| @@ -65,7 +60,11 @@ | ||
| 65 | 60 | const currentOf = (engine: Host): Host => host ?? engine |
| 66 | 61 | |
| 67 | 62 | const backendHostOf = (engine: Host): Backend.BackendHost => ({ |
| 68 | - run: (argv, init) => currentOf(engine).run(argv, init), | |
| 63 | + run: (argv, init) => | |
| 64 | + currentOf(engine).run( | |
| 65 | + argv, | |
| 66 | + pin.cwd === '' ? init : { cwd: pin.cwd, ...init }, | |
| 67 | + ), | |
| 69 | 68 | readFile: path => currentOf(engine).readFile(path), |
| 70 | 69 | mtimeOf: path => mtimeOf(currentOf(engine))(path), |
| 71 | 70 | entryKindsOf: dir => entryKindsOf(currentOf(engine))(dir), |
| @@ -95,7 +94,7 @@ | ||
| 95 | 94 | }) |
| 96 | 95 | |
| 97 | 96 | function pinBackend(engine: Host): Promise<boolean> { |
| 98 | - if (backend) { | |
| 97 | + if (backend || pin.isEmpty) { | |
| 99 | 98 | return Promise.resolve(true) |
| 100 | 99 | } |
| 101 | 100 | |
| @@ -125,6 +124,7 @@ | ||
| 125 | 124 | ) |
| 126 | 125 | |
| 127 | 126 | backend ??= probed |
| 127 | + pin.isEmpty = backend === null && asked.isAnswered | |
| 128 | 128 | |
| 129 | 129 | if (!probed || backend !== probed) { |
| 130 | 130 | return asked.isAnswered || backend !== null |
| @@ -148,6 +148,15 @@ | ||
| 148 | 148 | return true |
| 149 | 149 | } |
| 150 | 150 | |
| 151 | + function unpin() { | |
| 152 | + backend = null | |
| 153 | + pin.isEmpty = false | |
| 154 | + polled.toplevel = '' | |
| 155 | + polled.headKey = '' | |
| 156 | + timers.get('poll')?.cancel() | |
| 157 | + timers.delete('poll') | |
| 158 | + } | |
| 159 | + | |
| 151 | 160 | function dialogPane(): PaneOpenArgs { |
| 152 | 161 | return { |
| 153 | 162 | id: Names.PANE_ID, |
| @@ -423,26 +432,28 @@ | ||
| 423 | 432 | } |
| 424 | 433 | |
| 425 | 434 | const preference = await engine.storeGet(Names.STORE_OPEN_KEY) |
| 426 | - | |
| 427 | - await pinBackend(engine) | |
| 428 | - | |
| 429 | 435 | const isKeptOpen = preference === true |
| 430 | 436 | |
| 431 | 437 | const floor = isKeptOpen |
| 432 | 438 | ? Limits.OPEN_MIN_COLUMNS |
| 433 | 439 | : Limits.AUTO_OPEN_MIN_COLUMNS |
| 434 | 440 | |
| 435 | - const isEligible = | |
| 441 | + const hasRoom = | |
| 436 | 442 | preference !== false && |
| 437 | 443 | model.isFullscreen !== false && |
| 438 | 444 | columns !== null && |
| 439 | - columns >= floor && | |
| 440 | - backend !== null | |
| 445 | + columns >= floor | |
| 441 | 446 | |
| 442 | - if (!isEligible || isTaken()) { | |
| 447 | + if (!hasRoom || isTaken()) { | |
| 443 | 448 | return |
| 444 | 449 | } |
| 445 | 450 | |
| 451 | + await pinBackend(engine) | |
| 452 | + | |
| 453 | + if (!backend || isTaken()) { | |
| 454 | + return | |
| 455 | + } | |
| 456 | + | |
| 446 | 457 | hasAutoOpened = true |
| 447 | 458 | await openPane(engine, 'auto_open') |
| 448 | 459 | } |
| @@ -551,8 +562,9 @@ | ||
| 551 | 562 | redraw(engine) |
| 552 | 563 | } |
| 553 | 564 | |
| 554 | - async function bind(engine: Host): Promise<void> { | |
| 565 | + async function bind(engine: Host, cwd: string): Promise<void> { | |
| 555 | 566 | sessionStartMs = await engine.now() |
| 567 | + pin.cwd = cwd | |
| 556 | 568 | |
| 557 | 569 | try { |
| 558 | 570 | await engine.registerCommand(COMMAND_SPEC) |
| @@ -567,27 +579,30 @@ | ||
| 567 | 579 | } |
| 568 | 580 | |
| 569 | 581 | on('session.start', async ($, e, next) => { |
| 570 | - await bind({ | |
| 571 | - now: () => $.clock.now(), | |
| 572 | - after: (ms, fn) => $.clock.after(ms, fn), | |
| 573 | - every: (ms, fn) => $.clock.every(ms, fn), | |
| 574 | - run: (argv, init) => $.process.run(argv, init), | |
| 575 | - stat: path => $.fs.stat(path), | |
| 576 | - listDir: path => $.fs.list(path), | |
| 577 | - readFile: path => $.fs.read(path), | |
| 578 | - storeGet: key => $.store.get(key), | |
| 579 | - storeSet: (key, value) => $.store.set(key, value), | |
| 580 | - messages: () => $.session.messages(), | |
| 581 | - invalidate: () => $.ui.invalidate('ui.render'), | |
| 582 | - status: text => $.ui.status(text), | |
| 583 | - uiLog: text => $.ui.log(text), | |
| 584 | - openPane: pane => $.ui.open(pane), | |
| 585 | - closePane: pane => $.ui.close(pane), | |
| 586 | - registerCommand: spec => $.command.register(spec), | |
| 587 | - sessionId: () => $.session.id(), | |
| 588 | - mark: entry => $.telemetry.mark(entry), | |
| 589 | - log: entry => $.telemetry.log(entry), | |
| 590 | - }) | |
| 582 | + await bind( | |
| 583 | + { | |
| 584 | + now: () => $.clock.now(), | |
| 585 | + after: (ms, fn) => $.clock.after(ms, fn), | |
| 586 | + every: (ms, fn) => $.clock.every(ms, fn), | |
| 587 | + run: (argv, init) => $.process.run(argv, init), | |
| 588 | + stat: path => $.fs.stat(path), | |
| 589 | + listDir: path => $.fs.list(path), | |
| 590 | + readFile: path => $.fs.read(path), | |
| 591 | + storeGet: key => $.store.get(key), | |
| 592 | + storeSet: (key, value) => $.store.set(key, value), | |
| 593 | + messages: () => $.session.messages(), | |
| 594 | + invalidate: () => $.ui.invalidate('ui.render'), | |
| 595 | + status: text => $.ui.status(text), | |
| 596 | + uiLog: text => $.ui.log(text), | |
| 597 | + openPane: pane => $.ui.open(pane), | |
| 598 | + closePane: pane => $.ui.close(pane), | |
| 599 | + registerCommand: spec => $.command.register(spec), | |
| 600 | + sessionId: () => $.session.id(), | |
| 601 | + mark: entry => $.telemetry.mark(entry), | |
| 602 | + log: entry => $.telemetry.log(entry), | |
| 603 | + }, | |
| 604 | + e.cwd, | |
| 605 | + ) | |
| 591 | 606 | |
| 592 | 607 | return next(e) |
| 593 | 608 | }) |
| @@ -769,6 +784,7 @@ | ||
| 769 | 784 | await closePane(host).catch(() => undefined) |
| 770 | 785 | } |
| 771 | 786 | |
| 787 | + unpin() | |
| 772 | 788 | hasAutoOpened = false |
| 773 | 789 | bodyStamp = null |
| 774 | 790 | bodyBase = null |
| @@ -780,46 +796,28 @@ | ||
| 780 | 796 | return result |
| 781 | 797 | }) |
| 782 | 798 | |
| 783 | - on('tool.call', { tool: [...Tools.EDITING_TOOLS] }, async ($, e, next) => { | |
| 784 | - let result: ResultOf['tool.call'] | undefined | |
| 799 | + on( | |
| 800 | + 'tool.call', | |
| 801 | + { tool: [...Tools.EDITING_TOOLS, ...Tools.SHELL_TOOLS] }, | |
| 802 | + async ($, e, next) => { | |
| 803 | + const result = await next(e) | |
| 785 | 804 | |
| 786 | - try { | |
| 787 | - result = await next(e) | |
| 788 | - | |
| 789 | - return result | |
| 790 | - } finally { | |
| 791 | - if (host) { | |
| 805 | + if (host && !('deny' in result)) { | |
| 792 | 806 | if (isPaneOpen) { |
| 793 | 807 | scheduleRefresh(host) |
| 794 | 808 | } |
| 795 | 809 | |
| 796 | - const isEditDone = result !== undefined && !('deny' in result) | |
| 810 | + const isEdit = Tools.EDITING_TOOLS.some(name => name === e.tool) | |
| 797 | 811 | |
| 798 | - if (isEditDone) { | |
| 812 | + if (isEdit) { | |
| 799 | 813 | void openOnFirstEdit(host).catch(() => undefined) |
| 800 | 814 | } |
| 801 | 815 | } |
| 802 | - } | |
| 803 | - }) | |
| 804 | 816 | |
| 805 | - on('tool.call', { tool: [...Tools.SHELL_TOOLS] }, async ($, e, next) => { | |
| 806 | - try { | |
| 807 | - return await next(e) | |
| 808 | - } finally { | |
| 809 | - if (host && isPaneOpen) { | |
| 810 | - scheduleRefresh(host) | |
| 811 | - } | |
| 812 | - } | |
| 813 | - }) | |
| 817 | + return result | |
| 818 | + }, | |
| 819 | + ) | |
| 814 | 820 | |
| 815 | - on('turn.complete', ($, e, next) => { | |
| 816 | - if (host && isPaneOpen) { | |
| 817 | - scheduleRefresh(host) | |
| 818 | - } | |
| 819 | - | |
| 820 | - return next(e) | |
| 821 | - }) | |
| 822 | - | |
| 823 | 821 | on('prompt.submit', async ($, e, next) => { |
| 824 | 822 | const asked = armed |
| 825 | 823 |
| @@ -13,10 +13,10 @@ | ||
| 13 | 13 | expect((await Backend.backendOf(inGit.host, []))?.words.lister).toBe('git') |
| 14 | 14 | expect(await Backend.backendOf(outside.host, [])).toBeNull() |
| 15 | 15 | |
| 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']]) | |
| 20 | 20 | }) |
| 21 | 21 | |
| 22 | 22 | test('a declining probe leaves a git working tree to git', async () => { |
| @@ -27,7 +27,7 @@ | ||
| 27 | 27 | expect(backend?.words.lister).toBe('git') |
| 28 | 28 | expect(backend?.baseModes).toEqual(['session', 'uncommitted', 'branch']) |
| 29 | 29 | expect(declining.asked).toEqual([inGit.host]) |
| 30 | - expect(inGit.programs).toEqual(['git', 'git']) | |
| 30 | + expect(inGit.programs).toEqual(['git']) | |
| 31 | 31 | }) |
| 32 | 32 | |
| 33 | 33 | 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 @@ | ||
| 15 | 15 | export * from './fetch-diff' |
| 16 | 16 | export * from './git-hung.js' |
| 17 | 17 | export * from './git-in.js' |
| 18 | +export * from './git-word-of.js' | |
| 18 | 19 | export * from './head-key-of' |
| 19 | 20 | export * from './header-totals-of' |
| 20 | 21 | export * from './hint.js' |
| @@ -35,6 +36,7 @@ | ||
| 35 | 36 | export * from './pane.js' |
| 36 | 37 | export * from './pane-view' |
| 37 | 38 | export * from './parse-file-diff' |
| 39 | +export * from './poll-word.js' | |
| 38 | 40 | export * from './renamed.js' |
| 39 | 41 | export * from './repository.js' |
| 40 | 42 | 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 @@ | ||
| 20 | 20 | const diffs = pinned.filter(run => run.argv.includes('diff')) |
| 21 | 21 | |
| 22 | 22 | 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 | + | |
| 24 | 29 | expect(diffs.length).toBeGreaterThanOrEqual(3) |
| 25 | 30 | |
| 26 | 31 | for (const run of world.runs) { |
| @@ -38,6 +43,34 @@ | ||
| 38 | 43 | expect(world.runs.some(run => run.argv.includes('config'))).toBe(false) |
| 39 | 44 | }) |
| 40 | 45 | |
| 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 | + | |
| 41 | 74 | test('a file moved in since the start is session work', async ($, on) => { |
| 42 | 75 | const clock = Fixtures.startsSession(on, Fixtures.SETTLE_MS) |
| 43 | 76 |
| @@ -20,6 +20,107 @@ | ||
| 20 | 20 | }) |
| 21 | 21 | }) |
| 22 | 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 | + | |
| 23 | 124 | test('/diff whose probe never answers probes once more', async ($, on) => { |
| 24 | 125 | const probes: (readonly string[])[] = [] |
| 25 | 126 | const clock = Fixtures.startsSession(on) |
| @@ -340,19 +441,29 @@ | ||
| 340 | 441 | ).toContain('uncommitted (vs HEAD)') |
| 341 | 442 | }) |
| 342 | 443 | |
| 343 | - test('a repository made later is pinned once, on /diff', async ($, on) => { | |
| 444 | + test('no repository is an answer kept until /clear', async ($, on) => { | |
| 344 | 445 | const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } = |
| 345 | 446 | Fixtures.oneSecret() |
| 346 | 447 | |
| 347 | 448 | const script: Record<string, string> = notYet |
| 348 | 449 | const world = Fixtures.inRepository(on, script) |
| 349 | 450 | |
| 451 | + const probesOf = () => | |
| 452 | + world.runs.filter(run => run.argv.includes('--show-toplevel')).length | |
| 453 | + | |
| 454 | + on('command.run', { command: 'clear' }, () => ({})) | |
| 455 | + | |
| 350 | 456 | await $.session.start(Fixtures.WORKTREE_SESSION) |
| 351 | 457 | |
| 352 | 458 | const before = await $.command.run(Fixtures.DIFF) |
| 353 | 459 | |
| 354 | 460 | script['rev-parse --path-format=absolute'] = worktree |
| 355 | 461 | |
| 462 | + const again = await $.command.run(Fixtures.DIFF) | |
| 463 | + const probesKept = probesOf() | |
| 464 | + | |
| 465 | + await $.command.run(Fixtures.CLEAR) | |
| 466 | + | |
| 356 | 467 | const after = await $.command.run(Fixtures.DIFF) |
| 357 | 468 | |
| 358 | 469 | await world.clock.advance(Fixtures.SETTLE_MS) |
| @@ -365,20 +476,18 @@ | ||
| 365 | 476 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 366 | 477 | await third |
| 367 | 478 | |
| 368 | - const probes = world.runs.filter(run => | |
| 369 | - run.argv.includes('--show-toplevel'), | |
| 479 | + expect(before.text).toContain("isn't in a git repository") | |
| 480 | + | |
| 481 | + expect(again.text, "kept, as the built-in's answer is").toContain( | |
| 482 | + "isn't in a git repository", | |
| 370 | 483 | ) |
| 371 | 484 | |
| 372 | - expect(before.text).toContain("isn't in a git repository") | |
| 373 | - expect(after.text).toBe('Diff panel shown') | |
| 485 | + expect(probesKept, 'one probe answered both').toBe(1) | |
| 486 | + expect(after.text, '/clear forgot it').toBe('Diff panel shown') | |
| 374 | 487 | expect(world.opened[0]?.id).toBe('diff') |
| 488 | + expect(probesOf(), 'then one more, pinned from there on').toBe(2) | |
| 375 | 489 | |
| 376 | 490 | expect( |
| 377 | - probes, | |
| 378 | - 'the first two /diff probed; the third was pinned', | |
| 379 | - ).toHaveLength(2) | |
| 380 | - | |
| 381 | - expect( | |
| 382 | 491 | world.runs.some(run => run.argv.includes('--git-dir=/else/.git')), |
| 383 | 492 | ).toBe(false) |
| 384 | 493 |