From 9432d9f9e21eb1946d14ad8a611bedd2135526cf Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:23:55 +0800 Subject: [PATCH] feat(git): a Fetch button in the Git status header, and an interval for it app-wide and per case Fetch is the only git command Codeman runs that writes, and the only one that runs a repository's own fetch-post hook, so it stays behind a click and off by default. The Git status window's header row gains Fetch; App Settings -> Bottom bar gains the switch and the interval, synced because the scheduler acts on them, and Session Options -> Git overrides both per case. The scheduler reads settings fresh each tick, times the interval from the start of a run, never overlaps runs, and re-checks the Docker and remote rules every tick before any git runs. Nothing but remote-tracking refs moves: HEAD, the index and the working tree are proven unchanged against real git while origin/main advances. Tests: src/git-fetch.ts and the scheduler, the per-case settings route, the synced/display setting split, the two new i18n rows, and the Fetch block of the Git status browser suite. --- CLAUDE.md | 6 +- README.md | 2 +- docs/api-reference.md | 8 +- docs/wiki/Settings-Reference.md | 16 +- docs/wiki/Working-With-Files.md | 21 +- src/config/server-timing.ts | 11 + src/git-fetch-scheduler.ts | 166 +++++++++ src/git-fetch.ts | 246 ++++++++++++ src/git-workspace-status.ts | 14 +- src/hooks-config.ts | 20 + src/session.ts | 49 +++ src/types/session.ts | 11 + src/web/public/app.js | 3 + src/web/public/git-status-ui.js | 88 ++++- src/web/public/i18n.js | 29 +- src/web/public/index.html | 70 +++- src/web/public/session-ui.js | 89 +++++ src/web/public/settings-ui.js | 12 + src/web/public/styles.css | 48 ++- src/web/routes/git-status-routes.ts | 45 ++- src/web/routes/session-routes.ts | 33 ++ src/web/schemas.ts | 19 + src/web/server.ts | 56 +++ test/agent-inbox-server-wiring.test.ts | 1 + test/app-settings-structure.test.ts | 42 +++ test/git-fetch-i18n.test.ts | 101 +++++ test/git-fetch-scheduler.test.ts | 308 ++++++++++++++++ test/git-fetch.test.ts | 349 ++++++++++++++++++ test/git-status.browser.test.ts | 145 ++++++++ test/mocks/mock-session.ts | 17 + test/routes/git-status-routes.test.ts | 171 +++++++++ .../session-routes-git-fetch-settings.test.ts | 104 ++++++ test/session-options-structure.test.ts | 36 +- test/session-settings-recovery.test.ts | 32 ++ test/tab-layout-settings-i18n.test.ts | 11 +- 35 files changed, 2340 insertions(+), 39 deletions(-) create mode 100644 src/git-fetch-scheduler.ts create mode 100644 src/git-fetch.ts create mode 100644 test/git-fetch-i18n.test.ts create mode 100644 test/git-fetch-scheduler.test.ts create mode 100644 test/git-fetch.test.ts create mode 100644 test/routes/session-routes-git-fetch-settings.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index bd2594ac..b267b420 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,7 +234,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search) -**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate). +**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate). + +**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command Codeman runs that WRITES, and the only one that runs a repository's own `fetch-post` hook — which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree, since an agent is often working in the same repository. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, and bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `FETCH_CONCURRENCY` (4), `MAX_FETCH_OUTPUT_BYTES` (2 MB) and the git timeout per spawn. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-workspace-status.test.ts`). ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-case override wins even while the app-wide switch is off, the interval is timed from the run START, runs never overlap, and remote/Docker cases are re-checked every tick. ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-case state rides `SessionUpdated` (no new SSE event) as `gitFetch`. Tests: `test/git-fetch.test.ts`, `test/git-fetch-scheduler.test.ts`, `test/routes/session-routes-git-fetch-settings.test.ts`, `test/git-fetch-i18n.test.ts`, `test/app-settings-structure.test.ts`, `test/session-options-structure.test.ts`, and the Fetch block of `test/git-status.browser.test.ts` (browser suite, not in the gate). **Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view//...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `