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.
This commit is contained in:
Devvyn committed 2026-10-25 17:23:55 +08:00
1 parent 3e06f3c141
commit 9432d9f9e2
35 files changed
+2340 -39

No files matched your search

+1
View File
@@ -29,6 +29,7 @@ function bareServer(live: string[] = [], persisted: string[] = []): CleanupServe
Object.assign(server, {
sessions: new Map(live.map((id) => [id, {}])),
keepWarmService: { remove: () => {} },
gitFetchScheduler: { remove: () => {} },
store: {
getSession: () => null,
getSessions: () => Object.fromEntries(persisted.map((id) => [id, {}])),
+42
View File
@@ -12,6 +12,7 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
@@ -195,6 +196,47 @@ describe('App Settings modal structure', () => {
}
});
/**
* Git fetch on an interval (src/git-fetch-scheduler.ts). Both keys are SYNCED, not display keys, because
* the server's scheduler is what acts on them: kept per-device, the scheduler would read nobody's number.
* Off by default, and the interval clamped on save to the same bounds the schema accepts.
*/
it('keeps the Git fetch rows synced, off by default, and inside the bounds the scheduler acts on', () => {
const modal = settingsModal();
const bar = modal.match(/<h4>Bottom bar<\/h4>([\s\S]*?)(?=<div class="set-group-head">)/)?.[1];
expect(bar, 'the Git fetch rows are not in the Bottom bar group').toBeTruthy();
for (const id of ['appSettingsGitFetchEnabled', 'appSettingsGitFetchIntervalSeconds']) {
const at = modal.indexOf(`id="${id}"`);
expect(at, `no element ${id}`).toBeGreaterThan(-1);
const row = modal.slice(modal.lastIndexOf('<div class="set-row"', at), modal.indexOf('</div>', at));
expect(row).toContain('<span class="set-scope">synced</span>');
}
// The number row's own bounds must agree with the schema's, or the browser sends a value the PUT rejects.
expect(modal).toContain('id="appSettingsGitFetchIntervalSeconds" class="set-num" value="300" min="30" max="3600"');
// Display keys are the half of the per-device split that keeps a value off the server.
const displayStart = settingsUi.indexOf('const displayKeys = new Set([');
const displayBlock = settingsUi.slice(displayStart, settingsUi.indexOf(']);', displayStart));
expect(displayBlock).not.toContain('gitFetchEnabled');
expect(displayBlock).not.toContain('gitFetchIntervalSeconds');
// The strip destructure is the other half: an aliased key here never reaches the settings PUT.
expect(settingsUi).not.toMatch(/gitFetch(?:Enabled|IntervalSeconds):\s*_\w+/);
// Declared in the .strict() schema — an undeclared key 400s the whole settings PUT.
expect(SettingsUpdateSchema.safeParse({ gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }).success).toBe(true);
// Off by default (only an explicit true loads as on), and an empty box falls back to the default interval.
expect(settingsUi).toContain(
"document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true;"
);
expect(settingsUi).toContain(
"document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300;"
);
expect(settingsUi).toMatch(
/gitFetchIntervalSeconds: Math\.min\(\s*3600,\s*Math\.max\(30, parseInt\(document\.getElementById\('appSettingsGitFetchIntervalSeconds'\)\.value, 10\) \|\| 300\)\s*\),/
);
});
it('has retired the modal-tab chrome everywhere, not just here', () => {
// Session Options and Add Case moved onto this same `set-*` surface, so the
// old tab classes have no users left. A reappearance means a modal drifted
+101
View File
@@ -0,0 +1,101 @@
/**
* @fileoverview The Git fetch interval rows (App Settings → Bottom bar, Session Options → Git) in Chinese
* (zh-CN): each label and description runs through the real translator over the real index.html in JSDOM and
* reads in Chinese with no English left beyond the product name and the technical names Git / git fetch, and
* every new key — the save toast in session-ui.js included — is in the dictionary exactly once (a repeated key
* in the object literal silently replaces the first).
*
* The Git tab's Status row is not here: its text is written at runtime by updateGitFetchStatus and carries
* data-i18n-skip, so translating it would rewrite the stamp the code builds. 'sec' is not a key either — only
* whole unit words are ('hours'), so 'sec' stays as written.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
// Keys that contain a quote are written escaped in the object literal, so the dictionary is counted unescaped.
const DICT = I18N.replace(/\\'/g, "'").replace(/\\"/g, '"');
type Api = { configure(o: Record<string, string>): void; start(): void };
const ROWS = [
'appSettingsGitFetchEnabled',
'appSettingsGitFetchIntervalSeconds',
'modalGitFetchFollow',
'modalGitFetchEnabled',
'modalGitFetchInterval',
];
/** What may stay Latin: the product names, and Git / git fetch as technical names. */
const leftover = (text: string) =>
text.replace(/\b(Claude Code|Claude|Codeman|Ralph|Git|git|fetch)\b/g, '').match(/[A-Za-z]+/g) ?? [];
function rowStrings(doc: Document, id: string): { label: string; description: string } {
const row = doc.getElementById(id)!.closest('.set-row')!;
const label = row.querySelector('.set-row-label')!;
// The row's own scope marker ('synced') rides inside the label; the dictionary key is the label without it.
const scope = label.querySelector('.set-scope')?.textContent ?? '';
return {
label: label.textContent!.replace(scope, '').trim(),
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
};
}
/** The Git section's blurb, which is not a row. */
function blurb(doc: Document): string {
const section = doc.getElementById('git-tab')!;
return section.querySelector('.set-section-blurb')!.textContent!.trim();
}
const source = new JSDOM(INDEX);
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
afterAll(() => {
source.window.close();
dom.window.close();
});
describe('the Git fetch rows in zh-CN', () => {
it.each(ROWS)('translates the %s row, with no English left', (id) => {
const english = rowStrings(source.window.document, id);
const chinese = rowStrings(dom.window.document, id);
expect(english.label.length).toBeGreaterThan(0);
for (const key of ['label', 'description'] as const) {
expect(chinese[key]).not.toBe(english[key]);
expect(leftover(chinese[key])).toEqual([]);
}
});
it('translates the Git section blurb too, which is not a row', () => {
expect(blurb(dom.window.document)).not.toBe(blurb(source.window.document));
expect(leftover(blurb(dom.window.document))).toEqual([]);
});
it("leaves the Status row's runtime text to the code that writes it", () => {
// updateGitFetchStatus builds this row's sentence; a dictionary key for it would overwrite the stamp.
expect(INDEX).toContain('id="gitFetchStatus" data-i18n-skip hidden');
});
it('adds each key to the dictionary once, the save toast included', () => {
const texts = ROWS.flatMap((id) => Object.values(rowStrings(source.window.document, id)));
texts.push(blurb(source.window.document), 'Git fetch settings saved');
// 'sec' is deliberately not a key: only whole unit words are, so the unit stays as the markup writes it.
expect(DICT).not.toMatch(/\n sec: /);
for (const text of texts) {
const quoted = DICT.split(`'${text}'`).length + DICT.split(`"${text}"`).length - 2;
const bare = /^\w+$/.test(text) ? DICT.split(`\n ${text}: `).length - 1 : 0;
expect(quoted + bare, text).toBe(1);
}
});
});
+308
View File
@@ -0,0 +1,308 @@
/**
* @fileoverview The Git fetch interval scheduler (src/git-fetch-scheduler.ts): which cases a tick runs, and
* which it must not. Every rule is checked by calling `tick(now)` with an explicit clock, so nothing here
* depends on fake timers or on how long a real tick takes — the scheduler owns no timers itself.
*
* Port: N/A.
*/
import { describe, expect, it, vi } from 'vitest';
import {
GitFetchScheduler,
effectiveGitFetch,
type GitFetchPorts,
type GitFetchSessionView,
} from '../src/git-fetch-scheduler.js';
import {
FETCH_CONCURRENCY,
type GitFetchReport,
type GitFetchSettings,
type GitFetchStatus,
} from '../src/git-fetch.js';
import { DEFAULT_GIT_TIMEOUT_MS, MAX_REPOS } from '../src/git-workspace-status.js';
const SETTINGS: GitFetchSettings = { enabled: true, intervalSeconds: 300 };
const VIEW = (id: string, extra: Partial<GitFetchSessionView> = {}): GitFetchSessionView => ({
id,
workingDir: `/w/${id}`,
...extra,
});
/** An empty report, in the shape a run produces. */
const report = (cwd: string, name: string): GitFetchReport => ({
state: 'ok',
repos: [{ name, path: '.', ok: true }],
reposTruncated: false,
fetchedAt: Date.now(),
});
type Harness = {
scheduler: GitFetchScheduler;
ports: GitFetchPorts;
calls: Array<{ id: string; opts: unknown }>;
published: Array<{ id: string; status: GitFetchStatus }>;
settings: GitFetchSettings;
dockerReads: number;
};
/** A run the test decides when to let finish, so a slow repository can be held open across ticks. */
function deferred<T>(value: T) {
let resolve = (v: T) => {};
const promise = new Promise<T>((r) => (resolve = r));
return { promise, resolve };
}
/**
* A scheduler over an injected set of ports: nothing here touches a settings file, a repository or the
* server, so what is being pinned is the scheduler's own decisions.
*/
function harness(
sessions: GitFetchSessionView[],
opts: { settings?: GitFetchSettings; fetch?: (id: string) => Promise<GitFetchReport> } = {}
): Harness {
const customFetch = opts.fetch;
const settings = opts.settings ?? { ...SETTINGS };
const h: Harness = {
scheduler: undefined as unknown as GitFetchScheduler,
ports: undefined as unknown as GitFetchPorts,
calls: [],
published: [],
settings,
dockerReads: 0,
};
h.ports = {
sessionIds: () => sessions.map((s) => s.id),
getSession: (id) => sessions.find((s) => s.id === id),
readSettings: async () => {
if (h.settings === null) throw new Error('settings.json is mid-write');
return h.settings;
},
dockerWorkspaces: async () => {
h.dockerReads++;
return [];
},
fetch: async (cwd, portOpts) => {
const id = cwd.slice(3); // the '/w/<id>' the view was built with
h.calls.push({ id, opts: portOpts });
return customFetch ? customFetch(id) : report(cwd, id);
},
publish: (id, status) => h.published.push({ id, status }),
};
h.scheduler = new GitFetchScheduler(h.ports);
return h;
}
describe('a quiet tick', () => {
it('costs one settings read and nothing else, because nothing is on', async () => {
const h = harness([VIEW('a'), VIEW('b')], { settings: { enabled: false, intervalSeconds: 300 } });
expect(await h.scheduler.tick(1000)).toBe(0);
expect(h.calls).toEqual([]);
// The Docker case list is only worth reading once something is actually due.
expect(h.dockerReads).toBe(0);
});
it('reads settings FRESH every tick, so a live toggle takes effect on the next tick and not at boot', async () => {
const h = harness([VIEW('a')], { settings: { enabled: false, intervalSeconds: 30 } });
await h.scheduler.tick(0);
expect(h.calls.length).toBe(0);
h.settings = { enabled: true, intervalSeconds: 30 };
await h.scheduler.tick(1000);
expect(h.calls.length).toBe(1);
// Turning it off has to stop the fetching, not just stop it starting again later.
h.settings = { enabled: false, intervalSeconds: 30 };
await h.scheduler.tick(2000);
expect(h.calls.length).toBe(1);
});
it('skips this tick only when a settings read fails, because settings.json is rewritten in place', async () => {
const h = harness([VIEW('a')]);
h.settings = null as unknown as GitFetchSettings;
const logged = vi.fn();
h.ports.log = logged;
expect(await h.scheduler.tick(0)).toBe(0);
expect(h.calls).toEqual([]);
expect(logged).toHaveBeenCalled();
h.settings = SETTINGS;
// Nothing was recorded for the failed tick, so the case is still due at once.
expect(await h.scheduler.tick(1000)).toBe(1);
});
});
describe('who runs', () => {
it('runs every case the app setting covers, once per interval', async () => {
const h = harness([VIEW('a'), VIEW('b')], { settings: { enabled: true, intervalSeconds: 60 } });
await h.scheduler.tick(0);
expect(h.calls.map((c) => c.id).sort()).toEqual(['a', 'b']);
await h.scheduler.tick(30_000); // not due yet
expect(h.calls.length).toBe(2);
await h.scheduler.tick(60_000);
expect(h.calls.length).toBe(4);
});
it('runs a case that turned itself on even while the app-wide switch is off — that is what a per-case switch has to mean', async () => {
const h = harness([VIEW('plain'), VIEW('own', { gitFetchEnabled: true, gitFetchIntervalSeconds: 30 })], {
settings: { enabled: false, intervalSeconds: 300 },
});
await h.scheduler.tick(0);
expect(h.calls.map((c) => c.id)).toEqual(['own']);
// Its own interval, not the app's: due at 30 s, not at 300.
await h.scheduler.tick(30_000);
expect(h.calls.length).toBe(2);
});
it('never fetches a remote or Docker case, exactly as the Git panel excludes them', async () => {
const h = harness([
VIEW('ssh', { remote: { host: 'x' } }),
VIEW('docker', { docker: { container: 'c' } }),
VIEW('local'),
]);
await h.scheduler.tick(0);
expect(h.calls.map((c) => c.id)).toEqual(['local']);
});
it('re-checks the Docker rule every tick, so a case that became a Docker case stops fetching', async () => {
const sessions = [VIEW('a')];
const h = harness(sessions);
await h.scheduler.tick(0);
expect(h.calls.length).toBe(1);
sessions[0] = { ...sessions[0], docker: { container: 'c' } };
await h.scheduler.tick(400_000);
expect(h.calls.length).toBe(1);
});
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);
expect(h.calls[0].opts).toMatchObject({ maxRepos: MAX_REPOS, timeoutMs: DEFAULT_GIT_TIMEOUT_MS });
});
});
describe('one run at a time', () => {
it('skips a case still being fetched instead of stacking runs on it', async () => {
const gate = deferred<GitFetchReport>(report('/w/a', 'a'));
const h = harness([VIEW('a')], { settings: { enabled: true, intervalSeconds: 30 }, fetch: () => gate.promise });
const running = h.scheduler.tick(0);
await new Promise((resolve) => setImmediate(resolve)); // the run has started, and is still held open
expect(await h.scheduler.tick(15_000)).toBe(0);
expect(h.calls.length).toBe(1);
gate.resolve(report('/w/a', 'a'));
await running;
});
it('times the interval from the START of a run, so a slow repository does not push the next one out', async () => {
const slow = deferred<GitFetchReport>(report('/w/a', 'a'));
const h = harness([VIEW('a')], { settings: { enabled: true, intervalSeconds: 30 }, fetch: () => slow.promise });
const first = h.scheduler.tick(0);
await new Promise((resolve) => setImmediate(resolve));
expect(h.calls.length).toBe(1);
// The run takes 200 s, longer than its own interval. Once it lands, the case is already past due: the
// next tick runs it again instead of waiting another 30 s from when the slow one finished.
slow.resolve(report('/w/a', 'a'));
await first;
expect(await h.scheduler.tick(200_000)).toBe(1);
expect(h.calls.length).toBe(2);
});
it('runs at most FETCH_CONCURRENCY cases at a time, because each one is a git process', async () => {
const ids = Array.from({ length: FETCH_CONCURRENCY + 3 }, (_, i) => `s${i}`);
let live = 0;
let peak = 0;
const h = harness(
ids.map((id) => VIEW(id)),
{
fetch: async () => {
live++;
peak = Math.max(peak, live);
await new Promise((resolve) => setImmediate(resolve));
live--;
return report('/w/x', 'x');
},
}
);
await h.scheduler.tick(0);
expect(peak).toBe(FETCH_CONCURRENCY);
expect(h.calls.length).toBe(ids.length);
});
});
describe('what a run reports', () => {
it("publishes the run as the interval's own, so the panel can say the number was not asked for", async () => {
const h = harness([VIEW('a')]);
await h.scheduler.tick(0);
expect(h.published).toEqual([
{
id: 'a',
status: { ranAt: expect.any(Number), trigger: 'interval', state: 'ok', repos: 1, failed: 0 },
},
]);
});
it("reports a failure instead of retrying harder, and keeps the case's own next run at its own interval", async () => {
const h = harness([VIEW('a')], {
settings: { enabled: true, intervalSeconds: 30 },
fetch: () => Promise.reject(new Error('spawn git ENOENT')),
});
const logged = vi.fn();
h.ports.log = logged;
await h.scheduler.tick(0);
expect(logged).toHaveBeenCalled();
expect(h.published).toEqual([]);
// Still remembered, so the next run is on schedule rather than immediate.
expect(h.scheduler.tracked()).toBe(1);
});
});
describe('the effective choice', () => {
it("a case's own override wins over the app setting", () => {
const settings = { enabled: false, intervalSeconds: 600 };
expect(effectiveGitFetch(VIEW('a', { gitFetchEnabled: true, gitFetchIntervalSeconds: 45 }), settings)).toEqual({
enabled: true,
intervalSeconds: 45,
});
// Half a choice still follows the app for the other half.
expect(effectiveGitFetch(VIEW('b', { gitFetchEnabled: true }), settings)).toEqual({
enabled: true,
intervalSeconds: 600,
});
});
it('re-clamps a persisted interval that can predate the schema bounds', () => {
expect(effectiveGitFetch(VIEW('a', { gitFetchIntervalSeconds: 5 }), SETTINGS)).toEqual({
enabled: true,
intervalSeconds: 30,
});
expect(effectiveGitFetch(VIEW('b', { gitFetchIntervalSeconds: 9000 }), SETTINGS)).toEqual({
enabled: true,
intervalSeconds: 3600,
});
});
});
describe('a case that goes away', () => {
it('takes its schedule with it, so a reopened case starts on its own clock', async () => {
const h = harness([VIEW('a')]);
await h.scheduler.tick(0);
expect(h.scheduler.tracked()).toBe(1);
h.scheduler.remove('a');
expect(h.scheduler.tracked()).toBe(0);
await h.scheduler.tick(1000);
expect(h.calls.length).toBe(2); // remembered again, from now
});
it('stops everything after stop(), including a case that was already due', async () => {
const h = harness([VIEW('a')]);
h.scheduler.stop();
expect(await h.scheduler.tick(0)).toBe(0);
expect(h.calls).toEqual([]);
expect(h.scheduler.tracked()).toBe(0);
});
});
+349
View File
@@ -0,0 +1,349 @@
/**
* @fileoverview Tests for the Git panel's Fetch button (src/git-fetch.ts): the one git command Codeman
* runs that writes, and the one that runs a repository's own hooks — which is why it is a click, not a poll.
*
* Two halves, mirroring the module:
*
* 1. The PURE half — the fixed argv, the failure classification, and the two reads the interval scheduler
* depends on: what the settings say (`gitFetchSettingsFrom`) and what a run did
* (`gitFetchStatusFrom`). No spawning.
* 2. The IO half — a REAL git fetching a REAL local bare repo, so that "remote-tracking refs only" is
* proven against git's actual behavior: HEAD, the index and the working tree must be exactly what they
* were while the remote-tracking ref moves. The rest of the half uses an injected runner over real
* repositories on disk, because what is being pinned is WHICH repositories git is allowed to touch.
*
* Port: N/A (no server).
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { classifyGitFailure, isGitAvailable } from '../src/git-clone.js';
import {
FETCH_CONCURRENCY,
buildFetchArgs,
describeFetchFailure,
fetchGitWorkspaceOverview,
gitFetchSettingsFrom,
gitFetchStatusFrom,
type GitFetchReport,
} from '../src/git-fetch.js';
import { runGit, type GitRunner } from '../src/git-workspace-status.js';
const ENV = {
...process.env,
// As the production runner: git's messages are matched in English, and nothing reads the host's config.
LC_ALL: 'C',
GIT_AUTHOR_NAME: 'T',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 'T',
GIT_COMMITTER_EMAIL: 't@example.com',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
};
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, env: ENV, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
// ─── The pure half ───────────────────────────────────────────────────────────
describe('buildFetchArgs', () => {
it('pins the argv to a fetch and nothing else', () => {
expect(buildFetchArgs()).toEqual(['fetch', '--quiet']);
// Not just the first word: the whole argv is fixed, so no verb that changes the repository can slip in.
for (const verb of ['commit', 'pull', 'merge', 'checkout', 'reset', 'clean', 'prune', 'gc', 'amend']) {
expect(buildFetchArgs().join(' ')).not.toContain(verb);
}
});
it('names no repository, so the repository can only be the process cwd', () => {
// An operand-shaped argument would let a path or a URL choose the repository; the fixed argv cannot.
for (const arg of buildFetchArgs()) {
expect(arg.startsWith('-')).toBe(arg === '--quiet');
}
});
});
describe('describeFetchFailure', () => {
it("calls a repository with no remote a fact, not a fault, in each of git's wordings", () => {
for (const stderr of [
'fatal: no default remote repository -- use --remote or specify a repository',
'No remote configured',
'fatal: nothing to fetch from remote repository',
]) {
const failure = describeFetchFailure({ stderr });
expect(failure.code).toBe('NO_REMOTE');
expect(failure.message).toMatch(/no remote/i);
expect(failure.message).not.toMatch(/fail|error|unable/i);
}
});
it('writes its own timeout message, because the one to reuse was written for clones', () => {
const failure = describeFetchFailure({ code: 'ETIMEDOUT', stderr: 'fatal: repository not found' });
expect(failure.code).toBe('TIMEOUT');
expect(failure.message).toMatch(/Git status: git timeout/);
expect(failure.message).toMatch(/fetch from a shell/i);
expect(failure.message).not.toBe(classifyGitFailure('fatal: repository not found', true).message);
expect(describeFetchFailure({ killed: true, stderr: '' }).code).toBe('TIMEOUT');
});
it('reuses the network reasons it already knows', () => {
const prompt = "fatal: could not read Username for 'https://github.com': terminal prompts disabled";
expect(describeFetchFailure({ stderr: prompt }).code).toBe('AUTH_REQUIRED');
expect(describeFetchFailure({ stderr: 'git@github.com: Permission denied (publickey).' }).code).toBe(
'AUTH_REQUIRED'
);
expect(describeFetchFailure({ message: 'Error: spawn git ENOENT' }).code).toBe('GIT_MISSING');
expect(describeFetchFailure({ stderr: 'fatal: unable to access: Could not resolve host: nope.invalid' }).code).toBe(
'HOST_UNREACHABLE'
);
});
it('hands the client a redacted, control-stripped tail, never what git printed', () => {
const failure = describeFetchFailure({
stderr: "fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'\u001b[31m",
});
expect(failure.stderr).toBe("fatal: unable to access 'https://***:***@github.com/x.git/'[31m");
});
});
// ─── The two reads the interval feature depends on ───────────────────────────
describe('gitFetchSettingsFrom', () => {
it('is off until the user turns it on, and 300 s when nothing was chosen', () => {
// Absent means off: a quiet install must not start running git on a timer nobody asked for.
for (const settings of [undefined, {}, { gitFetchEnabled: false }, { gitFetchEnabled: 'true' }]) {
expect(gitFetchSettingsFrom(settings).enabled).toBe(false);
}
expect(gitFetchSettingsFrom({ gitFetchEnabled: true }).enabled).toBe(true);
expect(gitFetchSettingsFrom({}).intervalSeconds).toBe(300);
});
it('clamps a number outside the schema instead of trusting it, because a persisted value can predate the bounds', () => {
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 1 }).intervalSeconds).toBe(30);
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 99999 }).intervalSeconds).toBe(3600);
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 120.6 }).intervalSeconds).toBe(121);
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: -300 }).intervalSeconds).toBe(30);
// A hand-edited 0 is still a number it can act on, and acting on it means the smallest interval that runs.
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 0 }).intervalSeconds).toBe(30);
});
it('falls back to the default on a value it cannot act on, which is what a hand-edited settings.json can hold', () => {
for (const raw of ['300', undefined, null, NaN, Infinity, true]) {
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: raw }).intervalSeconds, String(raw)).toBe(300);
}
});
});
describe('gitFetchStatusFrom', () => {
it('counts what a run covered and what it could not do, and names the first failure', () => {
const report: GitFetchReport = {
state: 'ok',
repos: [
{ name: 'Codeman', path: '.', ok: true },
{ name: 'api', path: 'api', ok: false, failure: { code: 'NO_REMOTE', message: 'no remote', stderr: '' } },
],
reposTruncated: false,
fetchedAt: 1700,
};
expect(gitFetchStatusFrom(report, 'interval')).toEqual({
ranAt: 1700,
trigger: 'interval',
state: 'ok',
repos: 2,
failed: 1,
firstFailure: { name: 'api', code: 'NO_REMOTE' },
});
});
it('keeps the trigger, because a number the user did not ask for should read differently from one they did', () => {
const empty: GitFetchReport = { state: 'ok', repos: [], reposTruncated: false, fetchedAt: 1000 };
expect(gitFetchStatusFrom(empty, 'click').trigger).toBe('click');
expect(gitFetchStatusFrom(empty, 'interval').trigger).toBe('interval');
});
it('records a state that stopped the run, with nothing to count and no failure to name', () => {
const status = gitFetchStatusFrom(
{ state: 'unsupported', reason: 'docker', repos: [], reposTruncated: false, fetchedAt: 5 },
'interval'
);
expect(status.state).toBe('unsupported');
expect(status.repos).toBe(0);
expect(status.failed).toBe(0);
expect(status.firstFailure).toBeUndefined();
});
});
// ─── Real git, real local repository ─────────────────────────────────────────
const gitPresent = isGitAvailable();
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'git-fetch-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
function initRepo(path: string, remote?: string) {
mkdirSync(path, { recursive: true });
git(path, 'init', '-q', '-b', 'main');
// So a `file://` remote stays allowed for the production runner too, not only for this test's own git calls.
git(path, 'config', 'protocol.file.allow', 'always');
writeFileSync(join(path, 'f.txt'), '1\n');
git(path, 'add', '-A');
git(path, 'commit', '-q', '-m', 'init');
if (remote) git(path, 'remote', 'add', 'origin', remote);
return path;
}
/** Records what each repository was asked to do, and lets real git answer the rest. */
function recordingRunner() {
const calls: Array<[string, string[]]> = [];
const runner: GitRunner = async (cwd, args, opts) => {
calls.push([cwd, args]);
return runGit(cwd, args, opts);
};
return { calls, runner };
}
describe.skipIf(!gitPresent)('fetchGitWorkspaceOverview (real git)', () => {
let work: string;
const snapshot = (path: string) => ({
head: git(path, 'rev-parse', 'HEAD').trim(),
tree: readdirSync(path, { withFileTypes: true })
.filter((e) => e.name !== '.git' && e.isFile())
.sort((a, b) => a.name.localeCompare(b.name))
.map((e) => `${e.name}=${readFileSync(join(path, e.name), 'utf8')}`),
// The global option has to come before the verb, exactly as the read-only status module runs it.
index: git(path, '--no-optional-locks', 'status', '--porcelain=v2').trim(),
});
beforeEach(() => {
const origin = join(dir, 'origin.git');
mkdirSync(origin);
git(origin, 'init', '--bare', '--quiet', '-b', 'main');
work = initRepo(join(dir, 'work'), origin);
git(work, 'push', '-q', 'origin', 'main');
// A commit made elsewhere and pushed: work's origin/main cannot hear about it until a fetch runs.
// This has to be a clone of origin, not a fresh init — a commit on its own root commit is no
// descendant of what origin already holds, so pushing it is rejected as non-fast-forward.
const other = join(dir, 'other');
git(dir, 'clone', '-q', origin, 'other');
git(other, 'config', 'protocol.file.allow', 'always');
writeFileSync(join(other, 'g.txt'), '2\n');
git(other, 'add', '-A');
git(other, 'commit', '-q', '-m', 'second');
git(other, 'push', '-q', 'origin', 'main');
// Left dirty on purpose, so the snapshot has something proving it was not touched.
writeFileSync(join(work, 'untracked.txt'), 'u\n');
writeFileSync(join(work, 'f.txt'), 'modified\n');
});
it('fetches through the production runner, moving remote-tracking refs and writing nothing else', async () => {
// A push does not update the remote-tracking ref, so the fetch is what has to move it: 0 before, 1 after.
expect(git(work, 'rev-list', '--count', 'main..origin/main').trim()).toBe('0');
const before = snapshot(work);
const report = await fetchGitWorkspaceOverview(work);
expect(report.state).toBe('ok');
expect(report.repos).toHaveLength(1);
expect(report.repos[0]).toMatchObject({ name: 'work', path: '.', ok: true });
expect(report.fetchedAt).toBeGreaterThan(0);
expect(snapshot(work)).toEqual(before); // HEAD, the working tree and the index are exactly as they were
expect(git(work, 'rev-list', '--count', 'main..origin/main').trim()).toBe('1');
});
it('fetches exactly the repositories the panel would show, in the panel order', async () => {
const folder = join(dir, 'folder');
for (const name of ['api', 'web']) initRepo(join(folder, name));
mkdirSync(join(folder, 'node_modules', 'pkg'), { recursive: true }); // never a project the panel lists
const { calls, runner } = recordingRunner();
const report = await fetchGitWorkspaceOverview(folder, { git: runner });
expect(report.state).toBe('ok');
expect(report.repos.map((r) => [r.name, r.path])).toEqual([
['api', 'api'],
['web', 'web'],
]);
// Only the fetches, not the resolver's own `rev-parse`: the repositories touched are the point.
// A bounded run does not promise which one starts first, so compare the set.
expect(calls.filter(([, args]) => args[0] === 'fetch').sort()).toEqual([
[join(folder, 'api'), ['fetch', '--quiet']],
[join(folder, 'web'), ['fetch', '--quiet']],
]);
});
it('never reaches an unrelated repository above the workspace', async () => {
const home = join(dir, 'home');
initRepo(home);
const caseDir = join(home, 'case');
mkdirSync(caseDir);
const { calls, runner } = recordingRunner();
const report = await fetchGitWorkspaceOverview(caseDir, { git: runner, home });
expect(report.state).toBe('not-a-repo');
expect(calls.filter(([, args]) => args[0] === 'fetch')).toEqual([]);
});
it('refuses a repository at or inside a Docker case workspace before any git runs', async () => {
initRepo(join(dir, 'repo'));
const { calls, runner } = recordingRunner();
const report = await fetchGitWorkspaceOverview(dir, { git: runner, dockerWorkspaces: [dir] });
expect(report).toMatchObject({ state: 'unsupported', reason: 'docker' });
expect(calls).toEqual([]);
});
it('keeps a repository that could not be fetched, with its reason', async () => {
for (const name of ['ok', 'private']) initRepo(join(dir, 'folder', name));
const runner: GitRunner = async (cwd) => {
if (basename(cwd) === 'private') {
throw Object.assign(new Error('Command failed'), {
stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
});
}
return '';
};
const report = await fetchGitWorkspaceOverview(join(dir, 'folder'), { git: runner });
expect(report.state).toBe('ok');
const priv = report.repos.find((r) => r.name === 'private');
expect(priv?.ok).toBe(false);
expect(priv?.failure?.code).toBe('AUTH_REQUIRED');
expect(priv?.failure?.message).toMatch(/authentication/i);
expect(report.repos.find((r) => r.name === 'ok')?.ok).toBe(true);
});
it("runs at most FETCH_CONCURRENCY fetches at a time, with the setting's timeout", async () => {
const folder = join(dir, 'folder');
for (let i = 0; i < FETCH_CONCURRENCY + 2; i++) initRepo(join(folder, `r${i}`));
let inFlight = 0;
let max = 0;
const seenTimeouts = new Set<number | undefined>();
const runner: GitRunner = async (cwd, args, opts) => {
seenTimeouts.add(opts?.timeoutMs);
inFlight++;
max = Math.max(max, inFlight);
await new Promise((r) => setTimeout(r, 10));
inFlight--;
return '';
};
const report = await fetchGitWorkspaceOverview(folder, { git: runner, timeoutMs: 7000 });
expect(report.repos).toHaveLength(FETCH_CONCURRENCY + 2);
expect(report.repos.every((r) => r.ok)).toBe(true);
expect(max).toBe(FETCH_CONCURRENCY);
expect(seenTimeouts).toEqual(new Set([7000]));
});
it('answers not-a-repo for a folder that is not a repository, running no fetch', async () => {
const plain = join(dir, 'plain');
mkdirSync(plain);
const { calls, runner } = recordingRunner();
expect(await fetchGitWorkspaceOverview(plain, { git: runner })).toMatchObject({ state: 'not-a-repo', repos: [] });
expect(calls.filter(([, args]) => args[0] === 'fetch')).toEqual([]);
});
});
+145
View File
@@ -29,6 +29,7 @@ describe('Git status indicator in a real browser', () => {
let repoSession: string;
let plainSession: string;
const gitStatusRequests: string[] = [];
const gitFetchRequests: string[] = [];
const settingsPutStatuses: number[] = [];
const write = (rel: string, text = 'x\n') => writeFileSync(join(repo, rel), text);
@@ -91,6 +92,9 @@ describe('Git status indicator in a real browser', () => {
page.on('request', (r) => {
// The API route only: the page also loads /git-status-ui.js, whose URL contains the same words.
if (/\/api\/sessions\/[^/]+\/git-status/.test(r.url())) gitStatusRequests.push(r.url());
// Anchored: the per-case Git settings route (/git-fetch-settings) is a different route, and a bare
// /git-fetch test would count its PUTs as fetches. The fetch route carries the limits as a query.
if (/\/api\/sessions\/[^/]+\/git-fetch(\?|$)/.test(r.url())) gitFetchRequests.push(r.url());
});
page.on('response', (r) => {
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
@@ -514,6 +518,147 @@ describe('Git status indicator in a real browser', () => {
await refresh();
}, 40000);
it('puts Fetch in the header row, between Refresh and Close', async () => {
const order = await page.evaluate(() =>
[...document.querySelectorAll('.git-status-actions button')].map((b) => b.getAttribute('aria-label'))
);
expect(order).toEqual(['Refresh git status', 'Fetch git status', 'Close git status']);
// A repository the panel can read is a repository Fetch can fetch, so the button is live here.
expect(await page.evaluate(() => document.getElementById('gitStatusFetchBtn')!.disabled)).toBe(false);
});
it('Fetch fetches for real, moves the behind number, and reports what it brought', async () => {
// A second clone pushes a commit this repository has not fetched, so its remote-tracking ref is stale:
// the panel cannot show a `behind` number until a fetch runs. That is what the footer means by
// "as of the last fetch", and the number only becomes current by the button.
const other = join(root, 'other-clone');
git(root, 'clone', '-q', join(root, 'origin.git'), other);
writeFileSync(join(other, 'from-elsewhere.txt'), 'y\n');
git(other, 'add', '-A');
git(other, 'commit', '-q', '-m', 'from elsewhere');
git(other, 'push', '-q');
await refresh();
expect(await page.textContent('#gitStatusBody')).not.toMatch(/behind/);
const before = gitFetchRequests.length;
await page.click('#gitStatusFetchBtn');
await page.waitForFunction(
() => /↓ 1 behind/.test(document.getElementById('gitStatusBody')?.textContent ?? ''),
null,
{ timeout: 20000 }
);
expect(gitFetchRequests.length).toBe(before + 1);
// The same limits the status route gets go with the fetch, so the two agree on which repositories count.
expect(gitFetchRequests.at(-1)).toMatch(/maxRepos=12/);
expect(await page.locator('.git-status-chip', { hasText: 'behind' }).textContent()).toBe('↓ 1 behind');
expect(await page.textContent('#gitStatusBody .git-status-fetch-summary')).toMatch(
/^Fetched .+: 1 of 1 repository\.$/
);
expect(await page.locator('#gitStatusBody .git-status-fetch-row').count()).toBe(0);
// The 15 s poll rebuilds the whole body, so the note has to come from the renderer, not from the click.
await refresh();
expect(await page.locator('#gitStatusBody .git-status-fetch-summary').count()).toBe(1);
const foot = (await page.textContent('#gitStatusFooter')) ?? '';
expect(foot).toMatch(/Read-only apart from Fetch/);
// No longer "only when you click": an interval the user turned on fetches too, and the footer says so.
expect(foot).toMatch(/interval/);
// The chip itself says how it got current, so the number is not read as live.
expect(await page.locator('.git-status-chip', { hasText: 'behind' }).getAttribute('title')).toMatch(/interval/);
rmSync(other, { recursive: true, force: true });
}, 30000);
it('a repository Fetch could not fetch keeps its row and shows why', async () => {
await page.route('**/api/sessions/*/git-fetch*', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
state: 'ok',
reposTruncated: false,
fetchedAt: Date.now(),
repos: [
{ name: 'fetchable', path: 'fetchable', ok: true },
{
name: 'private',
path: '/srv/private',
ok: false,
failure: { code: 'AUTH', message: 'could not read the remote' },
},
],
},
}),
})
);
await page.click('#gitStatusFetchBtn');
await page.waitForFunction(() =>
/could not read the remote/.test(document.getElementById('gitStatusBody')?.textContent ?? '')
);
// The summary counts what landed, and the failure is named rather than folded into the count. The
// summary is the panel's first note line, so the count is read off it, not off the whole body.
expect(await page.textContent('#gitStatusBody .git-status-fetch-summary')).toMatch(
/^Fetched .+: 1 of 2 repositories\.$/
);
const row = (await page.textContent('#gitStatusBody .git-status-fetch-row')) ?? '';
expect(row).toContain('private');
expect(row).toContain('⚠ could not read the remote');
expect(await page.locator('#gitStatusBody .git-status-fetch-row').count()).toBe(1);
// Git text is untrusted: the row is text, never markup.
expect(await page.$('#gitStatusBody img')).toBeNull();
await page.unroute('**/api/sessions/*/git-fetch*');
}, 30000);
it('one click means one fetch: the button is busy while it runs and a second call is ignored', async () => {
let slow = true;
await page.route('**/api/sessions/*/git-fetch*', async (route) => {
if (slow) await new Promise((r) => setTimeout(r, 1500));
await route.continue();
});
page.setDefaultTimeout(6000);
await page.click('#gitStatusFetchBtn');
await page.waitForFunction(() => (window as any).app._gitFetchInFlight === true);
const busy = await page.evaluate(() => {
const b = document.getElementById('gitStatusFetchBtn')!;
return { ariaBusy: b.getAttribute('aria-busy'), disabled: b.disabled };
});
expect(busy.ariaBusy).toBe('true');
// Disabled is what stops a second click reaching the handler, so one click is one fetch per repository.
expect(busy.disabled).toBe(true);
const during = gitFetchRequests.length;
await page.evaluate(() => void (window as any).app.fetchGitStatusNow());
expect(gitFetchRequests.length).toBe(during);
slow = false;
// The flag goes false before the re-render that follows it, so wait for the button itself, not the flag:
// reading it right at the flip catches the panel mid-render.
await page.waitForFunction(
() => {
const b = document.getElementById('gitStatusFetchBtn')!;
return (window as any).app._gitFetchInFlight === false && !b.disabled && !b.getAttribute('aria-busy');
},
null,
{ timeout: 15000 }
);
// The handler is still asleep: unrouting without waiting fails the run as an unhandled rejection.
await page.unrouteAll({ behavior: 'wait' });
page.setDefaultTimeout(30000);
}, 30000);
it('Fetch is inert for a session nothing can be fetched for', async () => {
await select(plainSession);
await page.waitForFunction(
() => /No git repository here/.test(document.getElementById('gitStatusBody')?.textContent ?? ''),
null,
{ timeout: 15000 }
);
expect(await page.evaluate(() => document.getElementById('gitStatusFetchBtn')!.disabled)).toBe(true);
await select(repoSession);
await page.waitForFunction(() => document.getElementById('gitStatusFetchBtn')!.disabled === false, null, {
timeout: 15000,
});
await page.waitForSelector('#gitStatusPanel.visible');
}, 30000);
it('closing the panel resets it; turning the setting off hides the button, closes the panel and stops polling', async () => {
await page.click('.git-status-actions button[aria-label="Close git status"]');
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
+17
View File
@@ -11,6 +11,7 @@ import type {
SessionStatus,
SessionRemote,
} from '../../src/types.js';
import type { GitFetchStatus } from '../../src/git-fetch.js';
/**
* Enhanced mock session for testing RespawnController.
@@ -69,6 +70,22 @@ export class MockSession extends EventEmitter {
this.keepWarm = status;
return changed;
}
/**
* Mirrors the Git fetch interval surface on Session (src/git-fetch.ts): the case's own override, `undefined`
* for either half meaning the app-wide setting applies, and what the scheduler last reported.
*/
gitFetchEnabled: boolean | undefined = undefined;
gitFetchIntervalSeconds: number | undefined = undefined;
setGitFetchSettings(enabled: boolean | undefined, intervalSeconds: number | undefined): void {
this.gitFetchEnabled = enabled;
this.gitFetchIntervalSeconds = intervalSeconds;
}
gitFetch: GitFetchStatus | null = null;
setGitFetchStatus(status: GitFetchStatus): boolean {
const changed = JSON.stringify(status) !== JSON.stringify(this.gitFetch);
this.gitFetch = status;
return changed;
}
private _activeChildProcesses: { pid: number; command: string }[] = [];
ralphTracker: null = null;
writeBuffer: string[] = [];
+171
View File
@@ -353,6 +353,177 @@ describe('GET /api/sessions/:id/git-status limits', () => {
});
});
describe('POST /api/sessions/:id/git-fetch', () => {
/** Records what each repository was asked to do, and lets real git answer the rest. */
function recordingRunner() {
const calls: Array<[string, string[]]> = [];
const seenTimeouts: Array<number | undefined> = [];
const runner: GitRunner = async (cwd, args, opts) => {
calls.push([cwd, args]);
seenTimeouts.push(opts?.timeoutMs);
return execFileSyncGit(cwd, args);
};
return { calls, seenTimeouts, runner, fetches: () => calls.filter(([, args]) => args[0] === 'fetch') };
}
it('runs one fetch per repository the window would show, in the session working directory', async () => {
for (const name of ['api', 'web']) {
mkdirSync(join(dir, name));
git(join(dir, name), 'init', '-q', '-b', 'main');
}
const { calls, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner });
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(res.statusCode).toBe(200);
const data = res.json().data;
expect(data.state).toBe('ok');
expect(data.repos.map((r: { name: string; path: string }) => [r.name, r.path])).toEqual([
['api', 'api'],
['web', 'web'],
]);
expect(data.repos.every((r: { ok: boolean }) => r.ok)).toBe(true);
// A bounded run does not promise which repository starts first, so compare the set.
expect(fetches().sort()).toEqual([
[join(dir, 'api'), ['fetch', '--quiet']],
[join(dir, 'web'), ['fetch', '--quiet']],
]);
// Every repository git was asked to touch sits under the session folder, and under nothing else.
for (const [cwd] of calls) expect(cwd.startsWith(dir)).toBe(true);
});
it('keeps a repository it could not fetch, with its reason, and says which ones it fetched', async () => {
for (const name of ['ok', 'private']) {
mkdirSync(join(dir, name));
git(join(dir, name), 'init', '-q', '-b', 'main');
}
const { fetches, runner } = recordingRunner();
const injected: GitRunner = async (cwd, args, opts) => {
const recorded = runner(cwd, args, opts); // records the call before deciding what git would have said
if (args[0] === 'fetch' && cwd === join(dir, 'private')) {
throw Object.assign(new Error('Command failed'), {
stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
});
}
return recorded;
};
const { app } = await setup({ git: injected });
const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data;
const priv = data.repos.find((r: { name: string }) => r.name === 'private');
expect(priv?.ok).toBe(false);
expect(priv?.failure?.code).toBe('AUTH_REQUIRED');
expect(data.repos.find((r: { name: string }) => r.name === 'ok')?.ok).toBe(true);
// Both repositories are still in the list, so both were attempted: a failure does not cancel the run.
expect(fetches()).toHaveLength(2);
});
it('redacts credentials git may have echoed back before the failure reaches the client', async () => {
git(dir, 'init', '-q', '-b', 'main');
// Only the fetch is faked: the resolver still needs its own `rev-parse` to find the repository at all.
const runner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') {
throw Object.assign(new Error('Command failed'), {
stderr: "fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'\u001b[31m",
});
}
return execFileSyncGit(cwd, args);
};
const { app } = await setup({ git: runner });
const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data;
expect(data.state).toBe('ok');
expect(data.repos[0].failure.stderr).toBe("fatal: unable to access 'https://***:***@github.com/x.git/'[31m");
});
it.each([
['remote', { host: 'h', user: 'u' }],
['docker', { container: 'c' }],
])('does not run git for a %s session and says it is unsupported', async (kind, value) => {
git(dir, 'init', '-q', '-b', 'main');
const { calls, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner });
session[kind] = value;
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: 'unsupported', reason: kind });
expect(fetches()).toEqual([]);
expect(calls).toEqual([]); // the resolver never ran either: the refusal comes before any git
});
it('refuses a repository inside a Docker case workspace before any git runs', async () => {
git(dir, 'init', '-q', '-b', 'main');
const { calls, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner, dockerWorkspaces: [realpathSync(dir)] });
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(res.json().data).toMatchObject({ state: 'unsupported', reason: 'docker' });
expect(fetches()).toEqual([]);
expect(calls).toEqual([]);
});
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 });
const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data;
expect(data.state).toBe('not-a-repo');
// The resolver looked once and found nothing, so nothing was fetched — and never guessed a repository.
expect(fetches()).toEqual([]);
});
it('404s an unknown session, running no fetch', async () => {
const { calls, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner });
expect((await app.inject({ method: 'POST', url: '/api/sessions/nope/git-fetch' })).statusCode).toBe(404);
expect(fetches()).toEqual([]);
expect(calls).toEqual([]);
});
it('multi-user: another user’s session is not found, and no fetch runs', async () => {
process.env.CODEMAN_MULTIUSER = '1';
git(dir, 'init', '-q', '-b', 'main');
const { fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner, authUser: { username: 'bob', role: 'user' } });
session.owner = 'alice';
expect((await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).statusCode).toBe(404);
expect(fetches()).toEqual([]);
});
it('honours maxRepos and timeout (seconds) for a fetch too, clamped, and reports the limit it used', async () => {
for (const n of ['a', 'b', 'c']) {
mkdirSync(join(dir, n));
git(join(dir, n), 'init', '-q', '-b', 'main');
}
const { seenTimeouts, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner });
const post = async (query: string) => {
const res = await app.inject({ method: 'POST', url: `/api/sessions/test-session-1/git-fetch${query}` });
expect(res.statusCode).toBe(200);
return res.json().data as { repoLimit: number; repos: Array<{ name: string }> };
};
const fetchCount = () => fetches().length;
const ran = async (query: string, expected: number) => {
const before = fetchCount();
const data = await post(query);
expect(fetchCount() - before).toBe(expected);
return data;
};
const limited = await ran('?maxRepos=2&timeout=7', 2);
expect(limited.repos.map((r) => r.name)).toEqual(['a', 'b']);
expect(limited).toMatchObject({ reposTruncated: true, repoLimit: 2 }); // the window has to say three were found
expect(new Set(seenTimeouts)).toEqual(new Set([7000]));
const wild = await ran('?maxRepos=9999&timeout=1', 3);
expect(wild.repoLimit).toBe(50); // clamped to MAX_REPOS_LIMIT, so one request cannot scan the whole disk
expect(seenTimeouts.at(-1)).toBe(5000);
const junk = await ran('?maxRepos=abc&timeout=xyz', 3);
expect(junk.repoLimit).toBe(12);
expect(seenTimeouts.at(-1)).toBe(30_000);
// A repeated key reaches the route as an array: it means "default", never a 500.
await ran('?timeout=5&timeout=6', 3);
expect(seenTimeouts.at(-1)).toBe(30_000);
});
});
function execFileSyncGit(cwd: string, args: string[]): Promise<string> {
return Promise.resolve(execFileSync('git', ['--no-optional-locks', ...args], { cwd, env: ENV, encoding: 'utf8' }));
}
@@ -0,0 +1,104 @@
/**
* `POST /api/sessions/:id/git-fetch-settings` (session-routes.ts): the per-case half of the fetch interval
* (src/git-fetch-scheduler.ts) — the case's own switch and its own interval. `null` on either half clears
* that half so the app-wide setting applies again, and a body that names one row leaves the other as it was,
* because the Session Options rows save on their own. Refused for remote and Docker cases, the same rule the
* Git panel and the scheduler use. app.inject(), no port.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createHarness(): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('POST /api/sessions/:id/git-fetch-settings', () => {
let harness: Harness;
beforeEach(async () => {
harness = await createHarness();
});
afterEach(async () => {
await harness.app.close();
});
const post = (payload: unknown) =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/git-fetch-settings`, payload });
it('stores the case’s own choice, persists and broadcasts it', async () => {
const res = await post({ enabled: true, intervalSeconds: 120 });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: true, intervalSeconds: 120 });
expect(harness.ctx._session.gitFetchEnabled).toBe(true);
expect(harness.ctx._session.gitFetchIntervalSeconds).toBe(120);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
});
it('answers `null` for a case that follows the app, which is what the Session Options row shows', async () => {
const data = JSON.parse((await post({})).body).data.gitFetchSettings;
expect(data).toEqual({ enabled: null, intervalSeconds: null });
});
it('clears the override on null so the app-wide setting applies again', async () => {
await post({ enabled: true, intervalSeconds: 120 });
const res = await post({ enabled: null, intervalSeconds: null });
expect(res.statusCode).toBe(200);
expect(harness.ctx._session.gitFetchEnabled).toBeUndefined();
expect(harness.ctx._session.gitFetchIntervalSeconds).toBeUndefined();
expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: null, intervalSeconds: null });
});
it('leaves the other half as it was when only one row was saved', async () => {
await post({ enabled: true });
expect(harness.ctx._session.gitFetchIntervalSeconds).toBeUndefined(); // still following the app on that half
const res = await post({ intervalSeconds: 90 });
expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: true, intervalSeconds: 90 });
});
it('rejects an interval outside the schema bounds, storing nothing, because that is what the scheduler acts on', async () => {
// `null` is NOT on this list: it is the schema's way of clearing the override, tested above.
for (const raw of [29, 3601, 12.5, '120', true]) {
const res = await post({ enabled: true, intervalSeconds: raw });
expect(res.statusCode, String(raw)).toBe(400);
}
expect(harness.ctx._session.gitFetchEnabled).toBeUndefined();
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
});
it.each([
['remote', { host: 'h', user: 'u' }],
['docker', { container: 'c' }],
])('refuses a %s case, storing nothing', async (kind, value) => {
(harness.ctx._session as unknown as Record<string, unknown>)[kind] = value;
const res = await post({ enabled: true, intervalSeconds: 120 });
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(harness.ctx._session.gitFetchEnabled).toBeUndefined();
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
});
it('404s an unknown session, storing nothing', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/nope/git-fetch-settings',
payload: { enabled: true },
});
expect(res.statusCode).toBe(404);
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
});
});
+34 -2
View File
@@ -34,13 +34,18 @@ function methodBody(signature: string): string {
return sessionUi.slice(start, sessionUi.indexOf('\n },', start));
}
const TABS = ['respawn', 'context', 'ralph', 'summary'];
const TABS = ['respawn', 'context', 'ralph', 'summary', 'git'];
describe('Session Options modal structure', () => {
it('keeps every element openSessionOptions and switchOptionsTab touch by id', () => {
const modal = optionsModal();
const ids = new Set<string>();
for (const sig of ['openSessionOptions(sessionId)', 'switchOptionsTab(tabName)', 'getRalphConfig()']) {
for (const sig of [
'openSessionOptions(sessionId)',
'switchOptionsTab(tabName)',
'getRalphConfig()',
'updateGitFetchStatus(sessionId)',
]) {
for (const m of methodBody(sig).matchAll(/getElementById\('([A-Za-z0-9_-]+)'\)/g)) ids.add(m[1]);
}
// openSessionOptions also drives elements outside this modal (tabs, toasts);
@@ -83,6 +88,33 @@ describe('Session Options modal structure', () => {
expect(modal.match(/<button[^>]*data-tab="context"[^>]*>/)?.[0]).not.toContain('data-claude-only');
});
it('keeps the Git rail entry for every local case, and hides it for the ones nothing is ever fetched for', () => {
const modal = optionsModal();
// A shell case has repositories too, so Git must not ride the Claude-only flag.
expect(modal.match(/<button[^>]*data-tab="git"[^>]*>/)?.[0]).not.toContain('data-claude-only');
const open = methodBody('openSessionOptions(sessionId)');
expect(open).toContain('.set-rail-item[data-tab="git"]');
// Remote and Docker cases answer 'unsupported' on both git routes and the scheduler never fetches them,
// so the entry goes the same way the Claude-only ones do.
expect(open).toMatch(/gitTabBtn\.style\.display = session\.remote \|\| session\.docker \? 'none' : ''/);
});
it('makes the Git rows inert while a case follows the app, the way the keep-warm rows go inert', () => {
const modal = optionsModal();
const update = methodBody('updateGitFetchStatus(sessionId)');
expect(update).toContain('set-row-disabled');
expect(update).toContain('el.disabled = follows');
// Decided from BOTH fields: a half-choice means the case is not following, and the half that still follows
// shows the app's value while the other shows its own — a switch-only check would gray out a live row.
expect(update).toMatch(
/follows = session\.gitFetchEnabled === undefined && session\.gitFetchIntervalSeconds === undefined/
);
// Following saves null on both halves, which is what clears the override on the route.
expect(methodBody('async autoSaveGitFetch()')).toContain('{ enabled: null, intervalSeconds: null }');
// The status row is runtime text this method writes, so translating it would rewrite the stamp.
expect(modal).toContain('id="gitFetchStatus" data-i18n-skip hidden');
});
it('uses the shared settings surface rather than the modal-tab chrome', () => {
const modal = optionsModal();
expect(modal).toContain('class="modal-content modal-lg set-shell"');
+32
View File
@@ -40,6 +40,7 @@ describe('restoreMuxSessions() hands back every persisted user setting', () => {
['auto-compact', /session\.setAutoCompact\(/],
['auto-resume', /session\.restoreAutoResume\(/],
['nice priority', /session\.setNice\(/],
['git fetch', /gitFetchEnabled:\s*savedState\?\.gitFetchEnabled/],
])('%s', (_label, pattern) => {
expect(body).toMatch(pattern);
});
@@ -58,6 +59,15 @@ describe('restoreMuxSessions() hands back every persisted user setting', () => {
expect(body, `restoreMuxSessions lacks ${call}`).toContain(call);
}
});
it('re-applies the fetch choice only when the case actually chose one', () => {
// `undefined` is what "follows the app" means here, so a restore that called the setter unconditionally
// would hand every case the app default as if it had picked it, and the half-choice rows would read wrong.
const reapply = functionBody('reapplyPersistedSessionState');
expect(reapply).toMatch(
/if \(saved\.gitFetchEnabled !== undefined \|\| saved\.gitFetchIntervalSeconds !== undefined\)[\s\S]{0,160}session\.setGitFetchSettings\(saved\.gitFetchEnabled, saved\.gitFetchIntervalSeconds\)/
);
});
});
describe('the setters round-trip through toState()', () => {
@@ -83,4 +93,26 @@ describe('the setters round-trip through toState()', () => {
imageWatcherEnabled: true,
});
});
it('round-trips the fetch choice, and keeps a case that follows the app unchosen', () => {
const chose = new Session({ workingDir: '/tmp', mode: 'shell' });
chose.setGitFetchSettings(true, 120);
const state = chose.toState();
expect(state).toMatchObject({ gitFetchEnabled: true, gitFetchIntervalSeconds: 120 });
const rebuilt = new Session({
workingDir: '/tmp',
mode: 'shell',
gitFetchEnabled: state.gitFetchEnabled,
gitFetchIntervalSeconds: state.gitFetchIntervalSeconds,
});
expect(rebuilt.toState()).toMatchObject({ gitFetchEnabled: true, gitFetchIntervalSeconds: 120 });
// Undefined is the whole meaning of "follows the app": a restore that wrote the app default into the
// case would make it look like the case had picked it, and the row would stop following.
const follower = new Session({ workingDir: '/tmp', mode: 'shell' });
follower.setGitFetchSettings(undefined, undefined);
expect(follower.toState().gitFetchEnabled).toBeUndefined();
expect(follower.toState().gitFetchIntervalSeconds).toBeUndefined();
});
});
+7 -4
View File
@@ -140,11 +140,13 @@ describe('the Bottom bar settings group in zh-CN', () => {
/** What may stay Latin here: the git and Docker names, and the product name. */
const left = (text: string) => text.replace(/\b(Git|git|Docker|Codeman)\b/g, '').match(/[A-Za-z]+/g) ?? [];
it("finds the heading and the four Git status rows, #543's two included (the check is not vacuous)", () => {
expect(englishGroup.size).toBe(9);
it("finds the heading and the six Git rows, #543's two included and the fetch pair among them (the check is not vacuous)", () => {
expect(englishGroup.size).toBe(13);
expect(englishGroup.get('heading')).toBe('Bottom bar');
expect(englishGroup.get('appSettingsGitStatusMaxRepos label')).toBe('Git status: max repositories');
expect(englishGroup.get('appSettingsGitStatusTimeout label')).toBe('Git status: git timeout');
expect(englishGroup.get('appSettingsGitFetchEnabled label')).toBe('Git fetch on an interval');
expect(englishGroup.get('appSettingsGitFetchIntervalSeconds label')).toBe('Git fetch: how often');
});
it('translates the heading, every label and every description, with no English left', () => {
@@ -167,7 +169,8 @@ describe('the Bottom bar settings group in zh-CN', () => {
expect([...englishGroup.values()].filter((s) => t.t(s) !== s)).toEqual([]);
en.window.close();
const repeated = [...englishGroup.values()].filter((s) => {
const key = s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// A key with an apostrophe is written escaped in the object literal, so the pattern must allow both spellings.
const key = s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/'/g, "(?:\\\\)?'");
return (I18N.match(new RegExp(`^\\s*(?:'${key}'|"${key}"|${key}):`, 'gm')) ?? []).length !== 1;
});
expect(repeated).toEqual([]);
@@ -179,6 +182,6 @@ describe('the Bottom bar settings group in zh-CN', () => {
const labels = [...doc.querySelectorAll('#gitStatusPanel .git-status-actions button')].map((b) =>
b.getAttribute('aria-label')
);
expect(labels).toEqual(['刷新 Git 状态', '关闭 Git 状态']);
expect(labels).toEqual(['刷新 Git 状态', '拉取 Git 状态', '关闭 Git 状态']);
});
});