The forge panel opens a changes view in the right sidebar for the checkout's local changes and for the diff of each change request. - ForgePanelController reads local diffs in the branch, uncommitted, unstaged, and staged scopes through Git in the Session's read-only sandbox, change request diffs through the forge provider, and the text of a changed file to expand unchanged lines. Change request patches stay cached by head commit and base branch within maxCachedDiffBytes. A local diff over maxDiffBytes fails as forgePanel/diff-too-large, and a provider read over its output limit fails as too-large, which the view explains with a link to the code host; a gh timeout fails as unavailable. - forge adds the diff, fileText, and fileUrl provider methods and the too-large failure code, and forge-github implements them with gh. - The panel shows a local changes card that opens the view and asks the Agent for a change request. The view collapses files past a line budget, draws lines side by side or in one column with optional wrapping, expands unchanged lines on request, and collapses or expands every file from its toolbar; file headers use a bare chevron. - The checkout row copies the worktree or project directory path, the refresh glyph turns while a refresh runs, change request cards keep their actions while collapsed, and the header entry uses the shared compact button. - client-ui-forge classifies changeRequestKey and FsError as peer-required Host imports, which keeps forge and dsh-fs as its peers.
89 lines
3.3 KiB
TypeScript
89 lines
3.3 KiB
TypeScript
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { GhOutputLimitError, GhTimeoutError, GhUnavailableError, ghRunner } from '../src/gh.ts'
|
|
|
|
let root = ''
|
|
|
|
beforeEach(async () => {
|
|
root = await mkdtemp(join(tmpdir(), 'dsh-forge-gh-'))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllEnvs()
|
|
await rm(root, { recursive: true, force: true })
|
|
})
|
|
|
|
/** A POSIX stand-in for gh whose first argument selects its behavior. */
|
|
async function fakeGh(): Promise<string> {
|
|
const path = join(root, 'gh')
|
|
await writeFile(path, [
|
|
'#!/bin/sh',
|
|
'case "$1" in',
|
|
' echo) printf "%s|" "$@"; printf "\\nprompt=%s token=%s pager=[%s]\\n" "$GH_PROMPT_DISABLED" "${GH_TOKEN:-unset}" "$GH_PAGER" ;;',
|
|
' fail) echo oops >&2; exit 3 ;;',
|
|
' sleep) sleep 5 ;;',
|
|
' big) head -c 5000 /dev/zero | tr "\\0" x ;;',
|
|
'esac',
|
|
'',
|
|
].join('\n'))
|
|
await chmod(path, 0o755)
|
|
return path
|
|
}
|
|
|
|
describe.skipIf(process.platform === 'win32')('gh runner', () => {
|
|
it('runs gh with literal arguments and a scrubbed, non-interactive environment', async () => {
|
|
vi.stubEnv('GH_TOKEN', 'secret-token')
|
|
const run = ghRunner({ ghCommand: await fakeGh(), timeoutMs: 5_000, maxOutputBytes: 10_000 })
|
|
|
|
await expect(run(['echo', 'a b', '--flag'], new AbortController().signal)).resolves.toEqual({
|
|
exitCode: 0,
|
|
stdout: 'echo|a b|--flag|\nprompt=1 token=unset pager=[]',
|
|
stderr: '',
|
|
})
|
|
})
|
|
|
|
it('returns a nonzero exit code with its stderr', async () => {
|
|
const run = ghRunner({ ghCommand: await fakeGh(), timeoutMs: 5_000, maxOutputBytes: 10_000 })
|
|
|
|
await expect(run(['fail'], new AbortController().signal)).resolves.toEqual({ exitCode: 3, stdout: '', stderr: 'oops' })
|
|
})
|
|
|
|
it('rejects when gh outlives the timeout', async () => {
|
|
const run = ghRunner({ ghCommand: await fakeGh(), timeoutMs: 100, maxOutputBytes: 10_000 })
|
|
|
|
const result = run(['sleep'], new AbortController().signal)
|
|
|
|
await expect(result).rejects.toBeInstanceOf(GhTimeoutError)
|
|
await expect(result).rejects.toThrow('gh sleep timed out after 100ms')
|
|
})
|
|
|
|
it('rejects with the abort reason when cancelled', async () => {
|
|
const run = ghRunner({ ghCommand: await fakeGh(), timeoutMs: 5_000, maxOutputBytes: 10_000 })
|
|
const controller = new AbortController()
|
|
setTimeout(() => {
|
|
controller.abort(new Error('stop requested'))
|
|
}, 50)
|
|
|
|
await expect(run(['sleep'], controller.signal)).rejects.toThrow('stop requested')
|
|
})
|
|
|
|
it('rejects output beyond maxOutputBytes', async () => {
|
|
const run = ghRunner({ ghCommand: await fakeGh(), timeoutMs: 5_000, maxOutputBytes: 1_000 })
|
|
|
|
const result = run(['big'], new AbortController().signal)
|
|
|
|
await expect(result).rejects.toBeInstanceOf(GhOutputLimitError)
|
|
await expect(result).rejects.toThrow('gh big output exceeds maxOutputBytes')
|
|
})
|
|
|
|
it('reports an executable that cannot start', async () => {
|
|
const run = ghRunner({ ghCommand: join(root, 'missing-gh'), timeoutMs: 5_000, maxOutputBytes: 1_000 })
|
|
const result = run([], new AbortController().signal)
|
|
|
|
await expect(result).rejects.toBeInstanceOf(GhUnavailableError)
|
|
await expect(result).rejects.toThrow(/^gh could not start: /)
|
|
})
|
|
})
|