From 1752d7ee2cb35e76d119b105047819a7a06cc0aa Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 27 Oct 2026 02:58:37 +0100 Subject: [PATCH] fix: refuse to git fetch while the Docker case list cannot be read (release review) readDockerCases() reads an unreadable or half-written docker-cases.json as [], so a fetch (which runs repository hooks, and runs unattended on the interval) could run inside a Docker workspace. The fetch paths now read it through a strict reader: the Fetch button answers a failed run (state 'error') and the scheduler skips the tick, undoes its picks and logs once. The status and diff routes are unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/api-reference.md | 2 +- src/docker-hosts.ts | 26 ++++++++++ src/git-fetch-scheduler.ts | 38 +++++++++++++- src/web/routes/git-status-routes.ts | 34 +++++++++++-- src/web/server.ts | 3 +- test/docker-hosts.test.ts | 23 +++++++++ test/git-fetch-scheduler.test.ts | 40 ++++++++++++++- test/routes/git-status-routes.test.ts | 73 ++++++++++++++++++++++++--- 8 files changed, 221 insertions(+), 18 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index d8b0152d..2c7ef1d6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1340,7 +1340,7 @@ Lists are capped (300 files and 50 commits per repository) while the counts stay `data` is `{ state, repos, reposTruncated, repoLimit, fetchedAt }`, `repos[]` each `{ name, path, ok }` and, when it failed, `failure` (`{ code, message, stderr }`: the codes as in the clone failures, plus `NO_REMOTE`). A repository that could not be fetched keeps its row rather than being dropped. The panel shows the result under its list and then re-reads, so `behind` reflects what this fetch brought. -The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` — `null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was — is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. +The interval is two **synced** settings in `PUT /api/settings` (`src/git-fetch-scheduler.ts`): `gitFetchEnabled` (default `false`) and `gitFetchIntervalSeconds` (30 to 3600, default 300, the gap between runs timed from the start of a run so a slow repository does not push the next one out). Synced, not per-device, because the server is what acts on them; the scheduler reads them fresh on each 15 s tick, so a change works without a restart. A session can override both, and the override wins even while the app-wide switch is off: `POST /api/sessions/:id/git-fetch-settings` takes `{ "enabled": true | false | null, "intervalSeconds": number | null }` — `null` on a field clears that half so the app value applies again, a field left out of the body leaves the other half as it was — is `400 INVALID_INPUT` for a remote or Docker session, and answers `200` with `{ gitFetchSettings: { enabled, intervalSeconds } }` where `null` means the session follows the app. What the last run did rides the session state as `gitFetch` (`{ ranAt, trigger, state, repos, failed, firstFailure? }`, `trigger` is `click` or `interval`), on the existing `SessionUpdated` event: no new event. Sessions on the same folder share one run and both see its result, the schedule is keyed by folder, and a folder is fetched by at most one run at a time; a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins, and the folder is probed once for the whole group rather than once per session on it; every session open on a folder is remembered, so closing the one that holds the override does not stop that folder's fetching while another session is still open on it. The tick skips anything its bounded probe does not confirm is a directory, stamps each folder's schedule when it picks it, and does not run a second tick over a selection still going. Remote and Docker sessions are never fetched on a timer, and the Docker rule is re-checked against the CURRENT Docker roots every tick. Both fetch paths fail closed on a Docker case list (`docker-cases.json`) that exists but cannot be read or parsed: a click answers `200` with `state: 'error'` and publishes that as its `gitFetch`, and a tick fetches nothing and leaves its folders due for the next tick, since without the list nothing tells which folders a container can write to (the read-only status and diff routes keep reading such a list as no Docker workspaces). ## Git worktrees of a case diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index fbafb460..f2ce722d 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -130,6 +130,32 @@ export async function readDockerCases(configDir: string): Promise return readJsonArray(dockerCasesPath(configDir)); } +/** + * The Docker cases for a caller that must fail CLOSED (the git fetch paths): a missing file is no cases, + * but a file that cannot be read, does not parse or is not an array rejects, where {@link readDockerCases} + * reads all of those as `[]`. The writer truncates and rewrites the file in place, so a read can land on an + * empty or half-written file, and to a caller deciding where git may run that must never look like "no + * Docker workspaces". The parser's own message is not repeated (V8 quotes the text around the fault). + */ +export async function readDockerCasesOrThrow(configDir: string): Promise { + const path = dockerCasesPath(configDir); + let raw: string; + try { + raw = await fs.readFile(path, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = undefined; + } + if (!Array.isArray(parsed)) throw new Error(`${path} is not a valid JSON array`); + return parsed as DockerCase[]; +} + export async function writeDockerCases(configDir: string, cases: DockerCase[]): Promise { await writeJsonArray(configDir, dockerCasesPath(configDir), cases); } diff --git a/src/git-fetch-scheduler.ts b/src/git-fetch-scheduler.ts index a6d00578..a946a874 100644 --- a/src/git-fetch-scheduler.ts +++ b/src/git-fetch-scheduler.ts @@ -18,7 +18,10 @@ * app-wide switch is off — that is what a per-session switch has to mean. * - Remote and Docker sessions are never fetched, exactly as the Git panel excludes them, and the Docker * workspace roots are checked again every tick, so a session whose case became a Docker case stops - * fetching at the next tick. + * fetching at the next tick. A Docker case list that cannot be read fails CLOSED: the tick fetches + * nothing (a fetch runs a repository's hooks, and without the list nothing tells which folders a + * container can write to), the folders it picked stay due for the next tick, and it is logged once + * until a read succeeds again. * - A folder is fetched by at most one run at a time, and the run belongs to the FOLDER, not to one * session: two sessions open on the same folder are the same clone, so they share one run and both get * its result. State is keyed by `resolve(workingDir)` — deliberately not `realpath`, because `realpath` @@ -91,6 +94,10 @@ export interface GitFetchPorts { getSession(id: string): GitFetchSessionView | undefined; /** Read FRESH every tick; that is the whole point of the tick-driven shape. */ readSettings(): Promise; + /** + * Host paths of every Docker case workspace. REJECTS when the list exists but cannot be read or parsed; the + * tick then fetches nothing and the folders it picked are due again on the next tick. + */ dockerWorkspaces(): Promise; /** * Bounded probe (rule #516), default options: a bulk scan keeps the stall cap, so this must not be called @@ -134,6 +141,8 @@ export class GitFetchScheduler { private stopped = false; /** The last settings read failed and was logged; cleared by the next read that succeeds. */ private settingsUnreadable = false; + /** The same for the Docker case list. */ + private dockerCasesUnreadable = false; constructor(ports: GitFetchPorts) { this.ports = ports; @@ -185,6 +194,9 @@ export class GitFetchScheduler { // group ordered [short, long] and a group ordered [long, short] would have behaved differently, and the // run belongs to the folder, so no session in the group should. const due = new Map(); + // What each picked folder's clock said before this tick stamped it, so a tick that ends up fetching + // nothing (an unreadable Docker case list, below) can leave the folders due again. + const stampedOver = new Map(); for (const [folder, group] of groups) { if (this.inFlight.has(folder)) continue; const started = this.lastRunAt.get(folder); @@ -198,13 +210,35 @@ export class GitFetchScheduler { } this.inFlight.add(folder); // The clock starts at the selection, so a slow folder does not shift its own schedule. + stampedOver.set(folder, started); this.lastRunAt.set(folder, now); due.set(folder, group); } if (!due.size) return 0; // Only now, because nothing above needs it: a quiet tick does not read the Docker case list. - const dockerWorkspaces = await this.ports.dockerWorkspaces(); + let dockerWorkspaces: string[]; + try { + dockerWorkspaces = await this.ports.dockerWorkspaces(); + } catch (err) { + // Fail CLOSED: a fetch runs the repository's hooks, and without the list nothing tells which folders a + // container can write to. Nothing ran, so the picks are undone (a stamp a `remove()` already cleared + // stays cleared) and the folders are due again on the next tick instead of after their interval. + for (const [folder, started] of stampedOver) { + this.inFlight.delete(folder); + if (this.lastRunAt.get(folder) !== now) continue; + if (started === undefined) this.lastRunAt.delete(folder); + else this.lastRunAt.set(folder, started); + } + if (!this.dockerCasesUnreadable) { + this.dockerCasesUnreadable = true; + this.ports.log?.( + `git fetch: Docker case list unreadable, nothing fetched until it reads again: ${getErrorMessage(err)}` + ); + } + return 0; + } + this.dockerCasesUnreadable = false; // ONE bound for the git processes of the whole tick, handed to every run so no folder gets its own: // each fetch is one git process, and a folder can hold up to `MAX_REPOS_LIMIT` of them, so a bound per // run would let `FETCH_CONCURRENCY` folders × `MAX_REPOS_LIMIT` processes run at once. diff --git a/src/web/routes/git-status-routes.ts b/src/web/routes/git-status-routes.ts index 4bc9200a..1480b1cc 100644 --- a/src/web/routes/git-status-routes.ts +++ b/src/web/routes/git-status-routes.ts @@ -17,7 +17,7 @@ import type { FastifyInstance } from 'fastify'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; import { redactGitCredentials } from '../../git-clone.js'; import { emptyReport, fetchWorkspaceOnce, gitFetchStatusFrom, type GitFetchReport } from '../../git-fetch.js'; -import { readDockerCases } from '../../docker-hosts.js'; +import { readDockerCasesOrThrow } from '../../docker-hosts.js'; import { getDataDir } from '../../config/instance.js'; import { findSessionOrFail, persistAndBroadcastSession } from '../route-helpers.js'; import { @@ -38,9 +38,18 @@ import type { EventPort, SessionPort } from '../ports/index.js'; * Host paths of every Docker case workspace: repositories at or inside these are never inspected. * Exported so the interval scheduler (src/git-fetch-scheduler.ts) uses the SAME list — the Docker rule has * to be one rule, checked by every path that could run git. + * + * REJECTS when `docker-cases.json` exists but cannot be read or parsed (its writer truncates and rewrites it + * in place, so a read can land mid-write). The fetch paths fail closed on that: a fetch runs a repository's + * own hooks, and without the list nothing tells which folders a container can write to. The read-only + * status and diff routes read it as no Docker workspaces, as they always have. */ export const defaultDockerWorkspaces = async (): Promise => - (await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean); + (await readDockerCasesOrThrow(getDataDir())).map((c) => c.hostWorkspacePath).filter(Boolean); + +/** The read-only routes' reading of the list: unchanged, an unreadable list is no Docker workspaces. */ +const dockerWorkspacesOrNone = (dockerWorkspaces: () => Promise): Promise => + dockerWorkspaces().catch(() => []); /** * The repository limit and git timeout a request asks for (Settings → Bottom bar, per device): @@ -78,7 +87,7 @@ export function registerGitStatusRoutes( git, fresh: fresh === '1', ...limits, - dockerWorkspaces: await dockerWorkspaces(), + dockerWorkspaces: await dockerWorkspacesOrNone(dockerWorkspaces), }), }; }); @@ -102,7 +111,7 @@ export function registerGitStatusRoutes( ? await findWorkspaceRepo(session.workingDir, repo, { git, ...limits, - dockerWorkspaces: await dockerWorkspaces(), + dockerWorkspaces: await dockerWorkspacesOrNone(dockerWorkspaces), }) : null; const status = repoRoot @@ -150,9 +159,24 @@ export function registerGitStatusRoutes( const session = findSessionOrFail(ctx, id, req); if (session.remote) return { success: true, data: emptyReport('unsupported', { reason: 'remote' }) }; if (session.docker) return { success: true, data: emptyReport('unsupported', { reason: 'docker' }) }; + // Fail CLOSED on a Docker case list that cannot be read: a fetch runs the repository's hooks, so it never + // runs without knowing which folders a container can write to. Answered as a failed run (`state: 'error'`), + // published like one, so the Session Options Git row shows it. + let dockerRoots: string[]; + try { + dockerRoots = await dockerWorkspaces(); + } catch (err) { + console.warn(`[git fetch] Docker case list unreadable, nothing fetched: ${getErrorMessage(err)}`); + const report = emptyReport('error', { + error: + 'Nothing was fetched: Codeman could not read its Docker case list (docker-cases.json), so it cannot tell which folders a container can write to.', + }); + if (session.setGitFetchStatus(gitFetchStatusFrom(report, 'click'))) persistAndBroadcastSession(ctx, session); + return { success: true, data: report }; + } const run = await fetchWorkspaceOnce( session.workingDir, - { git, ...limits, dockerWorkspaces: await dockerWorkspaces() }, + { git, ...limits, dockerWorkspaces: dockerRoots }, 'click' ); // The response answers the click, and the run is also the session's current truth: what the panel's diff --git a/src/web/server.ts b/src/web/server.ts index 7d890281..c0799d46 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -406,7 +406,8 @@ export class WebServer extends EventEmitter { sessionIds: () => this.sessions.keys(), getSession: (id) => this.sessions.get(id), readSettings: readGitFetchSettings, - // The same Docker list the panel's routes use: the rule has to be one rule. + // The same Docker list the panel's Fetch button uses: the rule has to be one rule. It rejects on a list + // that cannot be read, and the tick then fetches nothing (fail closed). dockerWorkspaces: defaultDockerWorkspaces, // Bounded probe, DEFAULT options (rule #516): a tick is a bulk scan, so it keeps the stall cap and must // never be handed `pastCap`. A folder on a mount that stopped answering is skipped, not fetched. diff --git a/test/docker-hosts.test.ts b/test/docker-hosts.test.ts index 123c73ec..8913b64a 100644 --- a/test/docker-hosts.test.ts +++ b/test/docker-hosts.test.ts @@ -28,6 +28,7 @@ import { persistDockerCaseClaudeSessionId, probeDockerCliVersion, readDockerCases, + readDockerCasesOrThrow, readDockerHosts, resolveClaudeJsonSeedMount, resolveDockerClaudeArtifacts, @@ -70,6 +71,28 @@ describe('docker-hosts storage', () => { expect(await readDockerCases(dir)).toEqual([]); }); + /** + * The git fetch paths read the list through the strict reader: the writer truncates and rewrites the file in + * place, and a list read as `[]` there would let a fetch run git inside a Docker workspace. + */ + it('has a strict reader that rejects a list it cannot parse, where the plain reader returns []', async () => { + expect(await readDockerCasesOrThrow(dir)).toEqual([]); // missing: no cases + await writeDockerCases(dir, [CASE]); + expect(await readDockerCasesOrThrow(dir)).toEqual([CASE]); + const path = join(dir, 'docker-cases.json'); + for (const text of ['', '[{"name":"myproj","hostWorkspacePath":"/home/arkon/cas', '{"name":"myproj"}', 'null']) { + writeFileSync(path, text); + expect(await readDockerCases(dir)).toEqual([]); + const err = await readDockerCasesOrThrow(dir).then( + () => null, + (e: Error) => e + ); + expect(err?.message).toMatch(/not a valid JSON array/); + // The parser's message quotes the file; the strict reader's never does. + expect(err?.message).not.toContain('/home/arkon/cas'); + } + }); + it('persists the last Claude conversation id keyed by container name', async () => { await writeDockerCases(dir, [CASE, { ...CASE, name: 'other', container: 'custom-name' }]); await persistDockerCaseClaudeSessionId(dir, dockerContainerName(CASE.name), 'conv-1'); diff --git a/test/git-fetch-scheduler.test.ts b/test/git-fetch-scheduler.test.ts index cd936c3c..01f1eaee 100644 --- a/test/git-fetch-scheduler.test.ts +++ b/test/git-fetch-scheduler.test.ts @@ -12,7 +12,8 @@ * Port: N/A. */ import { mkdir, rm, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import { GitFetchScheduler, @@ -279,6 +280,43 @@ describe('who runs', () => { expect(h.calls.length).toBe(1); }); + /** + * The Docker case list is rewritten in place, so a read can land on a half-written file. Read as "no Docker + * workspaces", a tick would run git (and the repository's hooks) inside a folder a container can write to. + */ + it('fetches nothing when the Docker case list cannot be read, keeps the folders due, and logs it once', async () => { + const h = harness([VIEW('a'), VIEW('b')]); + let unreadable = true; + h.ports.dockerWorkspaces = async () => { + h.dockerReads++; + if (unreadable) throw new Error('docker-cases.json is not a valid JSON array'); + return []; + }; + const logged = vi.fn(); + h.ports.log = logged; + expect(await h.scheduler.tick(0)).toBe(0); + expect(await h.scheduler.tick(15_000)).toBe(0); + expect(h.calls).toEqual([]); + expect(h.dockerReads).toBe(2); // the picks were undone, so the next tick picked them again + expect(logged).toHaveBeenCalledTimes(1); + expect(h.scheduler.tracked()).toBe(0); // nothing left in flight or stamped + + // The list reads again: both folders run at once, not an interval after the failed ticks. + unreadable = false; + expect(await h.scheduler.tick(30_000)).toBe(2); + expect(h.calls.map((c) => c.cwd).sort()).toEqual(['/w/a', '/w/b']); + // And their interval is timed from that run. + expect(await h.scheduler.tick(30_000 + 299_000)).toBe(0); + expect(await h.scheduler.tick(30_000 + 300_000)).toBe(2); + }); + + it('is wired to the strict Docker reader the Fetch button uses', () => { + const server = readFileSync(resolve(import.meta.dirname, '../src/web/server.ts'), 'utf8'); + const block = server.slice(server.indexOf('new GitFetchScheduler(')); + // defaultDockerWorkspaces is the strict one: test/routes/git-status-routes.test.ts pins that it rejects. + expect(block.slice(0, block.indexOf('});'))).toContain('dockerWorkspaces: defaultDockerWorkspaces,'); + }); + it('uses the module defaults for the limit and the timeout, because those settings are per device and never reach the server', async () => { const h = harness([VIEW('a')]); await h.scheduler.tick(0); diff --git a/test/routes/git-status-routes.test.ts b/test/routes/git-status-routes.test.ts index fc5d345f..7e8269fd 100644 --- a/test/routes/git-status-routes.test.ts +++ b/test/routes/git-status-routes.test.ts @@ -9,7 +9,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRouteTestHarness } from './_route-test-utils.js'; -import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js'; +import { defaultDockerWorkspaces, registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js'; +import { getDataDir } from '../../src/config/instance.js'; import { clearGitStatusCache, runGit, type GitRunner } from '../../src/git-workspace-status.js'; import { clearGitFetchLane, fetchWorkspaceOnce } from '../../src/git-fetch.js'; @@ -30,14 +31,18 @@ let dir: string; let session: Record; async function setup( - opts: { git?: GitRunner; authUser?: { username: string; role: 'admin' | 'user' }; dockerWorkspaces?: string[] } = {} + opts: { + git?: GitRunner; + authUser?: { username: string; role: 'admin' | 'user' }; + /** A fixed list, a reader of the test's own, or `'real'` for the route's default (the data dir's file). */ + dockerWorkspaces?: string[] | (() => Promise) | 'real'; + } = {} ) { - const h = await createRouteTestHarness( - (app, ctx) => registerGitStatusRoutes(app, ctx, opts.git, async () => opts.dockerWorkspaces ?? []), - { - authUser: opts.authUser, - } - ); + const docker = opts.dockerWorkspaces; + const reader = docker === 'real' ? undefined : typeof docker === 'function' ? docker : async () => docker ?? []; + const h = await createRouteTestHarness((app, ctx) => registerGitStatusRoutes(app, ctx, opts.git, reader), { + authUser: opts.authUser, + }); session = h.ctx._session as unknown as Record; session.workingDir = dir; return h; @@ -461,6 +466,58 @@ describe('POST /api/sessions/:id/git-fetch', () => { expect(calls).toEqual([]); }); + /** + * docker-cases.json is rewritten in place (truncate, then write), so a read can land on an empty or + * half-written file. Read as "no Docker workspaces", a fetch would run git, and with it the repository's + * hooks, inside a folder a container can write to. + */ + it('fetches nothing when the Docker case list cannot be read, and answers a failed run', async () => { + git(dir, 'init', '-q', '-b', 'main'); + const { calls, runner } = recordingRunner(); + const { app, ctx } = await setup({ + git: runner, + dockerWorkspaces: async () => { + throw new Error('docker-cases.json is not a valid JSON array'); + }, + }); + const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(res.statusCode).toBe(200); + expect(res.json().data).toMatchObject({ state: 'error', repos: [] }); + expect(res.json().data.error).toMatch(/Docker case list/); + expect(calls).toEqual([]); // not even the resolver's rev-parse + expect(session.gitFetch).toMatchObject({ trigger: 'click', state: 'error', repos: 0, failed: 0 }); + expect(ctx.broadcast).toHaveBeenCalled(); + }); + + it('reads the real Docker case list strictly for a fetch, and leaves the status route as it was', async () => { + git(dir, 'init', '-q', '-b', 'main'); + mkdirSync(getDataDir(), { recursive: true }); + const listPath = join(getDataDir(), 'docker-cases.json'); + try { + writeFileSync(listPath, '[{"name":"c","hostWorkspacePath":"/x"'); + await expect(defaultDockerWorkspaces()).rejects.toThrow(/not a valid JSON array/); + const { calls, fetches, runner } = recordingRunner(); + const { app } = await setup({ git: runner, dockerWorkspaces: 'real' }); + const fetched = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(fetched.json().data.state).toBe('error'); + expect(calls).toEqual([]); + // The read-only status route still reads an unreadable list as none, as it always has. + const status = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' }); + expect(status.json().data.state).toBe('ok'); + expect(fetches()).toEqual([]); + + // A readable list that names this folder refuses the fetch as a Docker workspace, so the strict + // reader is the one the route really reads. + writeFileSync(listPath, JSON.stringify([{ name: 'c', hostWorkspacePath: realpathSync(dir) }])); + expect(await defaultDockerWorkspaces()).toEqual([realpathSync(dir)]); + const refused = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' }); + expect(refused.json().data).toMatchObject({ state: 'unsupported', reason: 'docker' }); + expect(fetches()).toEqual([]); + } finally { + rmSync(listPath, { force: true }); + } + }); + it('answers not-a-repo for a folder that is not a repository, running no fetch', async () => { const { fetches, runner } = recordingRunner(); const { app } = await setup({ git: runner });