Skip to content

feat(git): a Fetch button in the Git status header, and an interval for it app-wide and per case - #714

Open
opticon454 wants to merge 3 commits into
Ark0N:masterfrom
opticon454:feat/git-status-fetch
Open

opticon454 wants to merge 3 commits into
Ark0N:masterfrom
opticon454:feat/git-status-fetch

Conversation

@opticon454

Copy link
Copy Markdown
Contributor

What

Two things for the Git status window:

  1. Fetch in its header row, next to Refresh and Close. It runs git fetch for the repositories the panel lists and reports each repository's result.
  2. An interval. Off by default, in App Settings → Bottom bar (the switch and how often), with a per-case override of both in a new Session Options → Git section.

Why it stays opt-in

git fetch is the only git command Codeman runs that writes, and the only one that runs a repository's own fetch-post hook. An agent is often working in the same repository, so fetch moves remote-tracking refs and nothing else: HEAD, the index and the working tree are unchanged.

Details worth a look

  • The two settings are synced, not per-device, because the scheduler reads them server-side. They stay out of displayKeys and out of the saveAppSettings strip destructure in settings-ui.js — a per-device value never reaches the server.
  • Scheduler: settings read fresh on each 15 s tick, the interval is timed from the run start, runs never overlap, a per-case override wins even while the app-wide switch is off, and the Docker and remote rules are re-checked every tick. Bounds: FETCH_CONCURRENCY 4, repo limit 12/50, 2 MB output cap, the git timeout per spawn, non-interactive env (GIT_TERMINAL_PROMPT=0 + BatchMode ssh).
  • Per-case route POST /api/sessions/:id/git-fetch-settings: null clears that half, a field left out leaves the other, 400 for a remote or Docker case.
  • The result rides the existing SessionUpdated event as gitFetch; no new SSE event. Failure text reaching a client is control-stripped and credential-redacted, and the browser writes it with textContent.

Tests

  • test/git-fetch.test.ts — the fixed argv, the failure classification, and the two reads the scheduler depends on; a real git against a real local bare repository, proving the remote-tracking ref moves while HEAD, the index and the working tree do not.
  • test/git-fetch-scheduler.test.ts — tick semantics, override precedence, no overlap, Docker/remote refusal.
  • test/routes/session-routes-git-fetch-settings.test.ts, test/app-settings-structure.test.ts (the synced/display split), test/session-options-structure.test.ts, test/git-fetch-i18n.test.ts (the two new zh-CN rows).
  • test/git-status.browser.test.ts — the Fetch block, 22 tests (browser suite, not in the npm test gate).

Full gate: 635 test files passed (1 skipped), 12638 tests passed (12 skipped). Typecheck, lint, format:check, check:frontend-syntax (47 JS) and check:public-assets (68) are clean. No changesets added, nothing deployed.

Docs: CLAUDE.md, README, docs/api-reference.md, docs/wiki/Settings-Reference.md, docs/wiki/Working-With-Files.md.

…or 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.
@opticon454

Copy link
Copy Markdown
Contributor Author

Pushed as 9432d9f9 (branch feat/git-status-fetch, one commit, 35 files).

Two things in it that the diff alone does not make obvious:

  • The two settings are deliberately synced: they are in SettingsUpdateSchema and are kept out of _displaySettingKeySet() and out of the strip destructure in settings-ui.js, because the scheduler is the consumer. test/app-settings-structure.test.ts pins that split.
  • In the real-git fixture the second repository is a clone of origin.git, not a fresh git init. A commit on its own root commit is not a descendant of what origin/main already holds, so pushing it is refused as non-fast-forward, and the fixture only worked when the remote happened to be empty. As a clone, the second push fast-forwards, and the assertion still shows main..origin/main at 0 before the fetch and 1 after — which is the point: a push does not move the fetcher's own remote-tracking ref.

The test/agent-inbox-server-wiring.test.ts change is only the bare-prototype fixture gaining a gitFetchScheduler stub, since _doCleanupSession now calls remove() on it.

@Ark0N

Ark0N commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for following your Git status work through with it. The PR adds a Fetch button to the Git status window and an opt-in fetch interval, app-wide in Settings and per session in Session Options → Git.

The fetch itself is careful: fixed argv, the Docker rule checked before any git runs, a non-interactive environment, sanitized failure text, and the two settings wired as synced the way CLAUDE.md asks. Typecheck, lint, format, frontend syntax, the full npm test gate and the Git status browser test all pass here. The scheduler needs another round, because it is the part that runs with nobody watching:

1. Overlapping ticks run a queued case twice (src/git-fetch-scheduler.ts:128). The 15 s interval fires tick() without waiting for the previous one, and a case still queued behind the FETCH_CONCURRENCY workers is not yet in inFlight or stamped in lastRunAt, so the next tick picks it again. With 6 enabled cases, fetches held open, and tick(0) then tick(15000), I saw two cases fetched twice and 6 runs at once against a cap of 4. Please add the case to inFlight and stamp lastRunAt in the selection loop when it goes into due (or skip a tick while the previous one is still running), and add a test with more than FETCH_CONCURRENCY cases across two overlapping ticks. Also, each case runs its own mapLimited(dirs, FETCH_CONCURRENCY, ...) (src/git-fetch.ts:233), so one tick can start 16 git processes: either share one limiter or correct the bound in the fileoverview and CLAUDE.md.

2. Sessions on the same folder fetch the same repository at the same time (src/git-fetch-scheduler.ts:115). The schedule is keyed by session, and several sessions in one case folder is the usual setup, so with the app switch on they all fetch the same repositories on the same tick. Two concurrent git fetch in one clone after the remote moved: one of the pair failed with cannot lock ref 'refs/remotes/origin/main' in 15 of 15 rounds, which shows up as "1 failed" exactly when there was something to fetch. A Fetch click during a scheduled run (src/web/routes/git-status-routes.ts:137) has the same race. Please group the due sessions by folder (realpath of workingDir, or the resolved repository roots), fetch each folder once, publish the result to every session in it, and key inFlight/lastRunAt by folder so the click route can skip or join a run already going. Related: the override is stored per session but the UI says "Fetch this case", so two sessions in one case can disagree, and closing the one that holds the override stops that case's fetching. The two need to agree; which way to resolve that (store the override per case, or present it as a per-session setting) is still open.

3. Bounded probe before the unattended run (src/git-fetch-scheduler.ts:133). resolveWorkspaceRepos can realpath/opendir/lstat the folder without a bound, and on a network mount that stopped answering each call holds a libuv worker. CLAUDE.md's #516 rule asks for probePathKind() on user-chosen paths: please probe view.workingDir with the default options before the fetch and skip anything that is not 'directory'.

4. fetch-post is not a git hook (CLAUDE.md:239, src/git-fetch.ts:21, and the same sentence in src/web/schemas.ts:1495, the docs and the UI strings). What git fetch can run from a repository's config is the reference-transaction hook, pre-auto-gc through the automatic maintenance after a fetch, and config commands such as credential.helper. Please name those instead, and soften "an agent working in the same repository is not disturbed", since an agent's own git pull can hit the same cannot lock ref while a fetch holds the lock. Passing -c maintenance.auto=false -c gc.auto=0 in runFetch would also keep an unattended fetch from starting a gc in a repository an agent is using, without touching buildFetchArgs().

5. trigger: 'click' is never produced (src/git-fetch.ts:169). The click route never calls setGitFetchStatus, so the "by click" text in src/web/public/session-ui.js:3068 cannot appear and the API doc is half true. Either publish the click result on the session or drop 'click' from the type, UI and docs.

6. autoSaveGitFetch toasts "saved" on failure (src/web/public/session-ui.js:3099). _apiPost returns the raw Response and does not throw on a 400, so data?.gitFetchSettings is always undefined. Please use this._apiJson(path, { method: 'POST', body }), show the error toast on null, and apply the returned gitFetchSettings.

Two small wording fixes while you are in there: src/types/session.ts:903 calls gitFetch ephemeral while src/web/server.ts:413 says it is persisted, and the comment at src/web/public/index.html:1335 says the Git tab shows nothing until fetching is on, but it always shows for local sessions.

Once the scheduler changes are in, this will be a quick second look: the route, the fetch module, the settings wiring and the tests are in good shape.

…e review (review)

The scheduler is the part that runs with nobody watching, so it is decided per folder
rather than per session: sessions sharing a folder are the same clone and share one run
that both see, `inFlight`/`lastRunAt` are keyed by the resolved folder, the schedule is
stamped when a folder is picked instead of when its run starts, and a tick is skipped
while the previous one is still going. The click route joins a run already going for the
same folder rather than starting a second fetch in one clone.

The tick keeps ONE bound for the git processes of the whole run and hands it to every
folder, and bounds its folders with a bound of its own: the same semaphore cannot serve
both levels, because a folder holding a slot would then wait for a slot its own fetch
needs. Both bounds are `FETCH_CONCURRENCY`, so the ceiling is unchanged. The bound claim
in CLAUDE.md, the `Limiter` jsdoc and docs/api-reference.md now describes it as it is.

Before anything is fetched the folder is probed with the bounded path probe, so a case on
a mount that stopped answering is skipped and never touched.

The rest of the review: the hooks are named as they really are (`reference-transaction`,
`pre-auto-gc` through the maintenance a fetch triggers, config commands such as
`credential.helper`) and `FETCH_CONFIG_ARGS` turns maintenance and gc off for an
unattended run; the claim that an agent in the same repository is not disturbed is
replaced by the ref-lock collision it actually is; the click route publishes its own run
as `trigger: 'click'`; `autoSaveGitFetch` uses `_apiJson` so a 400 shows the error toast
and applies the returned settings; and the two wording fixes.

Tests: the scheduler pins one run per folder, the two bounds, the probe before anything
runs, and that closing the session holding the override keeps the folder while another
session is still open on it; the route test pins the click publish and the join, asserting
the join before the held run is released — releasing first lets the run finish and clears
the lane, so the joiner would be entitled to its own fetch and the test would pass for the
wrong reason.
@opticon454

Copy link
Copy Markdown
Contributor Author

Thanks — the scheduler round is in, pushed as ea431894 (the branch head was 9432d9f9).

1. Overlapping ticks — the schedule is stamped at selection: inFlight and lastRunAt are written in the selection loop when a folder goes into due, and tick() returns 0 while the previous tick is still running, so a case queued behind the workers cannot be picked twice. New test: FETCH_CONCURRENCY + 2 enabled sessions with fetches held open, tick(0) then tick(15_000) — the second tick returns 0 and every folder runs exactly once. On the bound you're right that mapLimited(dirs, FETCH_CONCURRENCY, ...) bounds each call, so the tick now creates one limiter and hands it to every run. The folders get a bound of their own, because the same semaphore cannot serve both levels: a folder that holds a slot and then waits for a slot for its own fetch is waiting on itself — once FETCH_CONCURRENCY folders fill it, the holders are exactly the ones that can never get one back. That showed up as the new test hanging rather than failing an assertion, so it's pinned as a peak-count test instead. Both bounds are FETCH_CONCURRENCY, so the tick's ceiling is unchanged. CLAUDE.md, createLimiter's jsdoc and docs/api-reference.md now say it this way.

2. Sessions on the same folder — grouped by resolve(workingDir): one run per folder, published to every session open on it, inFlight/lastRunAt keyed by folder, and the click route goes through fetchWorkspaceOnce, which joins a run already going. The new route test asserts the join before releasing the held run — releasing first lets the run finish and clears the lane, so the joiner would be entitled to start its own fetch and the test would pass for the wrong reason; my first version did exactly that and failed the gate for it. I kept resolve() rather than realpath because realpath is itself one of the unbounded fs calls rule #516 bounds, and the bounded probe is the fs read — so two paths that reach the same clone through a symlink can still look like two folders.

On the open question I'd keep storage per session and present it as per session: the row now reads "Fetch this session" (it said "Fetch this case"), and the scheduler remembers every session on a folder, so closing the one that holds the override no longer stops the folder's fetching while another session is still open on it. What per-session storage can't fix is two sessions in one case disagreeing — that's now the choice the row is labelled as, rather than a claim about the case. Happy to move it to per-case storage if you'd rather, but that's a storage change plus a route change, so I didn't do it unasked.

3. Bounded probe — probePathKind() with the default options on view.workingDir before anything is fetched, and anything that isn't directory is skipped and logged, with unknown never read as absent. The test covers file/unknown/absent/directory and that the probe happens before any fetch, and that a quiet tick doesn't read the Docker case list at all.

4. Hook names — CLAUDE.md, src/git-fetch.ts, src/web/schemas.ts, the docs and the UI strings now name reference-transaction, pre-auto-gc through the maintenance a fetch triggers, and config commands such as credential.helper. The "an agent working in the same repository is not disturbed" claim is replaced with the ref-lock collision. FETCH_CONFIG_ARGS is ['-c', 'core.fsmonitor=false', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0'], added by runFetch and not by buildFetchArgs(), which stays verb-invariant.

5. trigger: 'click' — the click route now publishes what it got, so the "by click" text can appear and the doc is fully true; a click that joined a tick already going publishes that run too. Pinned by a route test on trigger: 'click' with persist + broadcast.

6. autoSaveGitFetch — switched to this._apiJson(path, { method: 'POST', body }), the error toast on null, and the returned gitFetchSettings applied to the session. Pinned by the session-options structure test (no _apiPost in that method).

Both wording fixes are in: src/types/session.ts now says the state is written with the record but not restored across a restart, and the index.html comment says the Git tab shows for local sessions.

One thing I deliberately didn't change: the first commit's message still says "fetch-post hook". I'd rather not amend a commit that is already pushed, so the corrected naming lives in the follow-up commit and in the code.

Gate here: typecheck, lint, format:check and frontend syntax pass; the full npm test gate is green (635 test files, 12,648 tests, 12 skipped) and the Git status browser suite is 22/22.

@Ark0N

Ark0N commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Thanks for the second round, and for taking every point from the first one so thoroughly. To recap for anyone reading along: this PR adds a Fetch button to the Git status window and an opt-in fetch interval, app-wide in Settings and per session in Session Options → Git, run by a server-side scheduler.

All six round-one items and both wording fixes are in: the schedule is stamped at selection, a tick is skipped while the previous one runs, one shared limiter bounds the fetches, folders are grouped and the click joins a running fetch, the bounded probe runs first, the hooks are named correctly with FETCH_CONFIG_ARGS, the click publishes trigger: 'click', and autoSaveGitFetch uses _apiJson. Typecheck, lint, format, frontend syntax, the full npm test gate and CI are green. Two things in the interval path need another pass, plus some smaller ones:

1. A timed-out fetch leaves git's children running (src/git-fetch.ts:156). execFile's timeout sends SIGTERM to git only. With a remote that accepts the connection and never answers, runFetch(..., { timeoutMs: 3000 }) rejects after 3 s, and 1.5 s later git remote-http and git-remote-http are still running. On the interval that happens again every run, with nobody watching. runGit in src/git-clone.ts (around line 716) documents this exact hazard and handles it with detached: true and a process-group kill (SIGTERM, then SIGKILL). Please give fetch a spawn-based runner that does the same, with the output cap (it does not need the clone's slot queue), and add a test against a local server that never answers, asserting nothing is left running after the timeout.

2. Sessions on one folder do not all get its run (src/git-fetch-scheduler.ts:165). A session joins the folder's group only if an earlier session in the iteration already created it. Otherwise its own interval check skips it first. Two sessions on one folder with overrides of 3600 s and 30 s: in the order [long, short], the second tick publishes only short, and since the 30 s session keeps re-stamping the folder, the 3600 s session's Git row never updates again while the folder is fetched every 30 s. In the order [short, long] both are published. The existing test at test/git-fetch-scheduler.test.ts:239 covers only a first tick with equal intervals, so it passes in both orders. Please split the loop into two passes: collect every enabled local session by folder, decide per folder (due when any member's interval has elapsed), probe once, then publish to the whole group. Please also add a test with different intervals, in both orders, across two ticks.

The question from round one is still open: whether to store the override per case, or present it as a per-session setting as this round does. The two-pass fix is needed either way.

3. core.sshCommand is overridden (src/git-fetch.ts:159). gitNonInteractiveEnv() sets GIT_SSH_COMMAND, and git gives that precedence over a repository's core.sshCommand. A repository that fetches fine from a shell because its config picks a key or an ssh binary (several GitHub accounts, WSL with the Windows ssh.exe) fails here with an access error. That contradicts "works because that is the user's existing setup" (src/git-fetch.ts:34) and the matching lines in the wiki and docs/api-reference.md. Either leave GIT_SSH_COMMAND unset when git config --get core.sshCommand answers (the per-spawn timeout still bounds a prompt), or keep the override and say in those sentences that a repository-level core.sshCommand is not used. The same env also carries StrictHostKeyChecking=accept-new, so a fetch on the timer adds an unknown host key to ~/.ssh/known_hosts without asking. Please mention that next to the BatchMode note.

4. "The only git command Codeman runs that writes, and the only one that can run a repository's own hooks" (CLAUDE.md:239, src/web/schemas.ts:1494, src/git-fetch.ts:7, src/web/routes/git-status-routes.ts:135, docs/api-reference.md:1178, both wiki pages, src/web/public/index.html:1741, 2255 and 2283, and the zh-CN strings). git worktree add (src/git-worktrees.ts:844) writes and runs the post-checkout hook, git branch -d writes, and Clone writes a whole repository. Please scope it, for example "the only git command the Git panel runs that writes, and the only one Codeman runs on a timer", and move the zh-CN keys with the English strings.

5. Two leftovers in src/web/public/git-status-ui.js: lines 272-273 still say "an agent working in the same repository is not disturbed", and line 15 says Fetch runs "only on that click". The wiki also still says "a case with no choice of its own" for what is now a per-session override.

6. Small ones: CLAUDE.md:239 names test/git-workspace-status.test.ts for the buildFetchArgs() pin, but it lives in test/git-fetch.test.ts. And a click that joins an interval run is recorded as "by click" on the clicked session only (src/web/routes/git-status-routes.ts:160). Please record the trigger of the run that actually happened (interval, when the click joined a running tick).

Once 1 and 2 are in, this is a short third look: the routes, the settings wiring, the bounded probe and the tests are in good shape.

…ry session on its folder, and the rest of the review (review)

`execFile` signals the `git` parent only, so a fetch that times out can leave its http helper
still running, and on the interval that repeats every run with nobody watching. `runFetch` now
spawns `detached`, so the fetch and the helper it spawns share one process group, and the kill
is SIGTERM to the negative pid then SIGKILL `FETCH_KILL_ESCALATION_MS` later; the escalation is
not cancelled when the promise settles, because the caller is answered at the timeout while the
group is still dying. The output cap is kept, and the clone's slot queue is not needed here.

The tick is two passes: every enabled local session is collected by folder, a folder is due when
ANY session open on it is due, the folder is probed once for the group, and the run is published
to every session on it. A single pass decided by the order the sessions were read, so [long,
short] published only the short one and the 3600 s session's row never updated again while its
folder was fetched every 30 s.

`gitNonInteractiveEnv` writes BatchMode ssh as a default, not an override: `core.sshCommand` is
read inside the same slot and the same bound as the fetch it serves, and `fetchEnvFor` keeps the
repository's answer when it has one, because that is usually the setup that works on a machine
with several ssh setups. The `accept-new` host key it can write is stated next to the BatchMode
note: a prompt is stopped, a write is not.

The write claim is scoped to the Git panel and to the timer, with cloning and worktree management
named as the writes that happen when the user asked for them, and the zh keys sit with the group
of the UI surface their English string belongs to.

Tests: the process-group test proves the tree by group membership, because the helper is a
grandchild (`git fetch` runs `git remote-http`, which spawns `git-remote-http`) and because the
group is what the negative pid reaches, and it remembers each process once found instead of
re-scanning a group it had already emptied. The non-zero-exit test changed premises: `git fetch
--quiet` in a repository with no remote exits 0 on git 2.47.3, so resolving empty stdout there was
correct, and the case that has to reject is a remote pointing at a path that is not a repository.
The join test asserts after the run and does not await the request before the held run is
released, which would wait on the run the test is holding.
@opticon454

Copy link
Copy Markdown
Contributor Author

Round 2 actioned, in the code. 3411d7f3.

1. The timeout now reaches the whole tree (src/git-fetch.ts). runFetch is a spawn with detached: true, so the fetch and the helper it spawns share one process group, and the kill is SIGTERM to the negative pid, then SIGKILL FETCH_KILL_ESCALATION_MS (3 s) later. The escalation is deliberately not disarmed when the promise settles: the caller is answered at the timeout while the tree is still dying, which is the point. Output cap kept, no clone slot queue. The new test (test/git-fetch.test.ts) runs against a local server that accepts and never answers and asserts nothing is left in the group afterwards. Two corrections it needed: the helper is a grandchild — git fetch runs git remote-http, which spawns git-remote-http — so the test proves group membership rather than parentage, which is the same thing the fix depends on; and each process is remembered once found, because after the kill the group is empty and a test that kept re-scanning would report a tree it had already destroyed.

The non-zero-exit test changed premises while I wrote it: git fetch --quiet in a repository with no remote exits 0 on the git here (2.47.3) and prints nothing, so resolving empty stdout there is the correct behaviour, not the bug. It now uses a remote pointing at a path that is not a repository, which exits 128 and keeps its stderr; describeFetchFailure reads that as NOT_FOUND. Its NO_REMOTE branch stays pinned by the classifier test against git's own wording, and is now documented as defensive rather than as something this git produces for the plain fetch path.

2. A tick is two passes (src/git-fetch-scheduler.ts): collect every enabled local session by folder, decide per folder (due when any member's interval has elapsed), probe once for the group, then publish to every session on it. New tests with 3600 s and 30 s overrides in both orders across two ticks; [long, short] is the order that failed, and the existing equal-interval test still covers the first tick.

3. core.sshCommand is no longer overridden. readRepoSshCommand reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and fetchEnvFor is now a pure decision: BatchMode is written only when the environment had nothing, so a repository whose config names a key or an ssh binary keeps its own answer. The accept-new record is stated next to the BatchMode note in CLAUDE.md, both wiki pages and docs/api-reference.md — BatchMode stops a prompt, not a write.

4. Scoped as suggested: "the only git command the Git panel runs that writes, and the only one Codeman runs on a timer", with cloning and worktree management named as the writes that happen when the user asked. On the zh keys I read the instruction as "each key sits with the group of the UI surface its English string belongs to": the two App Settings → Bottom bar rows moved into the Bottom bar group in the order the page lists them, and the Session Options → Git keys are their own block in the session-dialogs region. Since a dictionary key is the English string it translates, the reworded sentences had to change in both places, and test/git-fetch-i18n.test.ts asserts they still match.

5. Both leftovers rewritten: line 15 now says the panel fetches on the click, or on the interval when the session is on; the "not disturbed" line now says what is true — nothing commits or touches the working tree, but a fetch does hold git's ref lock, so an agent's git pull in the same repository can hit the same cannot lock ref. The wiki's "a case with no choice of its own" is now per-session.

6. CLAUDE.md names test/git-fetch.test.ts for the verb pin. GitFetchRun carries the trigger of the run that happened, the route and the scheduler publish that instead of the request's own, and the routes test asserts a click that joined an interval run records interval — asserted after the run, so it can only pass if the click reached the lane and joined.

The open question from round 1, unchanged: per-session storage, with the UI reading "Fetch this session". The two-pass fix is what carries the folder either way, so I did not change the storage.

Gate: typecheck, lint, format:check, check:frontend-syntax, npm test (635 files passed | 1 skipped, 12,657 tests passed | 12 skipped) and the browser suite (22/22 in test/git-status.browser.test.ts) are green.

@Ark0N

Ark0N commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Thanks for the third round, and for following every point from the second one through to a test. This PR adds a Fetch button to the Git status window and an opt-in fetch interval, app-wide in Settings and per session in Session Options → Git, run by a server-side scheduler.

All of round two is in: the timeout now reaches the whole process group (your test against a server that never answers passes here), a tick decides per folder in a second pass with both orders pinned, a repository's core.sshCommand is honoured and the accept-new write is documented, the wording is scoped to the Git panel, and the run's own trigger is published. Typecheck, lint, format, frontend syntax, the full npm test gate, the Git status browser test (22/22) and CI are green.

Three small bugs are left, each reproduced against the shipped code:

1. A session update resets "How often" while you type (src/web/public/session-ui.js:3059, called from src/web/public/app.js:2460). _onSessionUpdated runs updateGitFetchStatus for the session whose options are open, and that rewrites num.value and box.checked every time. A working session sends session:updated on each working/idle change, so a half-typed "12" goes back to "300" before onchange fires. Please leave a control alone while it is document.activeElement, or have the SSE path refresh only #gitFetchStatus.

2. A partial override shows defaults instead of the app values (src/web/public/session-ui.js:3054-3055). The route takes one field at a time, but the tab fills the missing half with false and 300. With the app on at 900 s, a session holding only intervalSeconds: 120 shows its switch off while the scheduler fetches it every 120 s, and one holding only enabled: true shows 300 while it runs at 900. Touching any row then saves what is shown. The comment above those lines already describes the fix: session.gitFetchEnabled ?? (app.gitFetchEnabled === true) and session.gitFetchIntervalSeconds ?? app.gitFetchIntervalSeconds ?? 300.

3. The core.sshCommand read doubles the timeout (src/git-fetch.ts:237). readRepoSshCommand gets the full timeoutMs, then the spawn arms a fresh timeoutMs timer, so with a git that never answers, runFetch(..., { timeoutMs: 1000 }) rejected after 2019 ms here. The comment at src/git-fetch.ts:201-202 and the new CLAUDE.md line say the read spends part of the fetch's bound. Please give the spawn what is left of the bound (or cap the read at a few seconds) so both sentences stay true.

Smaller ones:

  • src/web/public/i18n.js:541: the zh-CN text for "Git fetch on an interval" still says the only git command {name} runs that writes. The English and the other zh strings scope it to the Git window (Git 窗口运行的唯一会写入的 git 命令).
  • src/web/public/styles.css:7156: .btn-icon-sm[disabled] and [aria-busy] reach every small icon button, so the File Viewer's Back/Forward and the folder grid's Up button change look when disabled. Please scope them to .git-status-actions .btn-icon-sm and stop the busy pulse under prefers-reduced-motion.
  • src/git-fetch.ts:191: a GIT_SSH in the server's environment is still overridden by the BatchMode default (git reads GIT_SSH_COMMAND, then core.sshCommand, then GIT_SSH). Treating it like the repository's own answer (repoSshCommand || base.GIT_SSH) keeps that setup working.
  • A few comments still call the override per case: src/session.ts:618 and 1597, src/git-fetch-scheduler.ts:2, src/config/server-timing.ts:78, src/web/server.ts:1553, src/web/public/session-ui.js:3035.

The design question from the first two rounds is still open: whether the override is stored per case, or stays per session as the Session Options rows now present it.

All of the above is small, so I will fix it on top at merge time; you do not need to push another round for it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants