Merge #543: configurable git status repository limit and git timeout, unreadable repositories stay listed

# Conflicts:
#	src/web/public/styles.css
This commit is contained in:
Codeman maintainer
2026-10-09 05:32:55 +02:00
13 changed files with 429 additions and 48 deletions
+76
View File
@@ -398,6 +398,82 @@ describe('Git status indicator in a real browser', () => {
await page.waitForSelector('#gitStatusPanel.visible');
}, 30000);
it('sends the max-repositories and git-timeout settings, and lists an unreadable repository with its reason', async () => {
const setLimits = async (maxRepos: string, timeout: string) => {
await page.evaluate(() => (window as any).app.openAppSettings());
await page.fill('#appSettingsGitStatusMaxRepos', maxRepos);
await page.fill('#appSettingsGitStatusTimeout', timeout);
await page.evaluate(() => (window as any).app.saveAppSettings());
await page.waitForTimeout(300);
await page.evaluate(() => (window as any).app.closeAppSettings());
};
await setLimits('7', '45');
// Per-device keys must never reach the strict PUT /api/settings.
expect(settingsPutStatuses.every((st) => st === 200)).toBe(true);
expect(await page.evaluate(() => (window as any).app.gitStatusLimits())).toEqual({ maxRepos: 7, timeout: 45 });
await refresh();
expect(gitStatusRequests.at(-1)).toMatch(/maxRepos=7/);
expect(gitStatusRequests.at(-1)).toMatch(/timeout=45/);
// Out-of-range values are clamped when saved, not sent as typed.
await setLimits('9999', '1');
expect(await page.evaluate(() => (window as any).app.gitStatusLimits())).toEqual({ maxRepos: 50, timeout: 5 });
// A folder with more repositories than the limit, one of which git could not read.
const emptyCounts = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 };
const status = (over: Record<string, unknown>) => ({
state: 'ok',
branch: 'main',
detached: false,
upstream: 'origin/main',
upstreamGone: false,
ahead: 0,
behind: 0,
hasRemote: true,
counts: emptyCounts,
files: [],
filesTruncated: false,
unpushedCount: 0,
unpushed: [],
checkedAt: Date.now(),
...over,
});
await page.route('**/api/sessions/*/git-status*', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
state: 'ok',
reposTruncated: true,
repoLimit: 2,
checkedAt: Date.now(),
repos: [
{ name: 'fast', path: 'fast', status: status({ repoRoot: '/x/fast' }) },
{ name: 'slow', path: 'slow', status: status({ state: 'error', error: 'git timed out' }) },
],
},
}),
})
);
await refresh();
await page.waitForFunction(() =>
/could not read/.test(document.getElementById('gitStatusBody')?.textContent ?? '')
);
const body = (await page.textContent('#gitStatusBody')) ?? '';
expect(body).toContain('slow');
expect(body).toContain('could not read: git timed out');
expect(body).toContain('Showing the first 2 of more than 2 repositories');
expect(body).toContain('Raise “Git status: max repositories”');
// The unreadable repository must keep the indicator from claiming everything is fine.
expect(await label()).toContain('? 1');
expect(await label()).not.toContain('✓');
expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/1 repository could not be read/);
await page.unroute('**/api/sessions/*/git-status*');
await setLimits('12', '30');
await refresh();
}, 40000);
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);
+96
View File
@@ -896,3 +896,99 @@ describe('Docker case workspaces are never inspected', () => {
expect(o.state).toBe('ok');
});
});
import {
clampInt,
DEFAULT_GIT_TIMEOUT_MS,
MAX_GIT_TIMEOUT_MS,
MAX_REPOS_LIMIT,
MIN_GIT_TIMEOUT_MS,
resolveOverviewLimits,
} from '../src/git-workspace-status.js';
describe('configurable repository limit and git timeout', () => {
let top: string;
let home: string;
const repoAt = (p: string): string => {
mkdir(p, { recursive: true });
git(p, 'init', '-q', '-b', 'main');
writeFileSync(join(p, 'f.txt'), '1\n');
git(p, 'add', '-A');
git(p, 'commit', '-q', '-m', 'c');
return p;
};
beforeEach(() => {
top = mkdtempSync(join(tmpdir(), 'git-limits-'));
home = join(top, 'home');
mkdir(home, { recursive: true });
clearGitStatusCache();
});
afterEach(() => rmSync(top, { recursive: true, force: true }));
it('clampInt keeps untrusted values inside the range, and falls back for anything that is not a number', () => {
expect(clampInt('7', 1, 50, 12)).toBe(7);
expect(clampInt(7.9, 1, 50, 12)).toBe(7);
expect(clampInt(0, 1, 50, 12)).toBe(1);
expect(clampInt(9999, 1, 50, 12)).toBe(50);
for (const bad of [undefined, null, '', 'abc', NaN, Infinity, {}]) expect(clampInt(bad, 1, 50, 12)).toBe(12);
});
it('resolveOverviewLimits defaults to 12 repositories and a 30 s timeout, and clamps both ends', () => {
expect(resolveOverviewLimits({})).toEqual({ maxRepos: MAX_REPOS, timeoutMs: DEFAULT_GIT_TIMEOUT_MS });
expect(DEFAULT_GIT_TIMEOUT_MS).toBe(30_000);
expect(resolveOverviewLimits({ maxRepos: 500, timeoutMs: 10 ** 9 })).toEqual({
maxRepos: MAX_REPOS_LIMIT,
timeoutMs: MAX_GIT_TIMEOUT_MS,
});
expect(resolveOverviewLimits({ maxRepos: -3, timeoutMs: 1 })).toEqual({
maxRepos: 1,
timeoutMs: MIN_GIT_TIMEOUT_MS,
});
});
it('lists up to maxRepos, says what the limit was, and a different limit is not answered from the old cache', async () => {
const ws = join(home, 'case');
for (const n of ['a', 'b', 'c', 'd', 'e']) repoAt(join(ws, n));
const three = await getGitWorkspaceOverview(ws, { home, maxRepos: 3 });
expect(three.repos.map((r) => r.name)).toEqual(['a', 'b', 'c']);
expect(three).toMatchObject({ reposTruncated: true, repoLimit: 3 });
// No `fresh`: the 30 s discovery cache must be keyed by the limit.
const ten = await getGitWorkspaceOverview(ws, { home, maxRepos: 10 });
expect(ten.repos).toHaveLength(5);
expect(ten).toMatchObject({ reposTruncated: false, repoLimit: 10 });
});
it('keeps a repository git could not read in the list, with the reason, instead of dropping it', async () => {
const ws = join(home, 'case');
for (const n of ['fast', 'slow']) repoAt(join(ws, n));
const flaky: GitRunner = (cwd, args, opts) => {
if (cwd.endsWith('slow')) return Promise.reject(Object.assign(new Error('timed out'), { killed: true }));
return runGit(cwd, args, opts);
};
const o = await getGitWorkspaceOverview(ws, { home, git: flaky });
expect(o.repos.map((r) => [r.name, r.status.state])).toEqual([
['fast', 'ok'],
['slow', 'error'],
]);
expect(o.repos[1].status.error).toBe('git timed out');
});
it('passes the timeout to every git command, clamped', async () => {
const ws = join(home, 'case');
repoAt(join(ws, 'a'));
const seen: Array<number | undefined> = [];
const spy: GitRunner = (cwd, args, opts) => {
seen.push(opts?.timeoutMs);
return runGit(cwd, args, opts);
};
await getGitWorkspaceOverview(ws, { home, git: spy, timeoutMs: 45_000, fresh: true });
expect(seen.length).toBeGreaterThan(0);
expect(new Set(seen)).toEqual(new Set([45_000]));
clearGitStatusCache();
seen.length = 0;
await getGitWorkspaceOverview(ws, { home, git: spy, timeoutMs: 10 ** 9, fresh: true });
expect(new Set(seen)).toEqual(new Set([MAX_GIT_TIMEOUT_MS]));
});
});
+47
View File
@@ -298,3 +298,50 @@ describe('GET /api/sessions/:id/git-diff in a folder of several repositories', (
expect(calls.filter(([, verb]) => verb === 'status').map(([cwd]) => cwd)).toEqual([api]);
});
});
describe('GET /api/sessions/:id/git-status limits', () => {
it('honours maxRepos and timeout (seconds), 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 seen: Array<number | undefined> = [];
const spy: GitRunner = (cwd, args, opts) => {
seen.push(opts?.timeoutMs);
return execFileSyncGit(cwd, args);
};
const { app } = await setup({ git: spy });
const res = await app.inject({
method: 'GET',
url: '/api/sessions/test-session-1/git-status?maxRepos=2&timeout=7',
});
const data = res.json().data;
expect(data.repos.map((r: { name: string }) => r.name)).toEqual(['a', 'b']);
expect(data).toMatchObject({ reposTruncated: true, repoLimit: 2 });
expect(new Set(seen)).toEqual(new Set([7000]));
clearGitStatusCache();
const wild = await app.inject({
method: 'GET',
url: '/api/sessions/test-session-1/git-status?maxRepos=9999&timeout=1&fresh=1',
});
expect(wild.json().data.repoLimit).toBe(50);
expect(seen.at(-1)).toBe(5000);
clearGitStatusCache();
const junk = await app.inject({
method: 'GET',
url: '/api/sessions/test-session-1/git-status?maxRepos=abc&timeout=xyz&fresh=1',
});
expect(junk.json().data.repoLimit).toBe(12);
expect(seen.at(-1)).toBe(30_000);
clearGitStatusCache();
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status?maxRepos=&timeout=&fresh=1' });
expect(seen.at(-1)).toBe(30_000); // empty means "not given", not 0
});
});
function execFileSyncGit(cwd: string, args: string[]): Promise<string> {
return Promise.resolve(execFileSync('git', ['--no-optional-locks', ...args], { cwd, env: ENV, encoding: 'utf8' }));
}