mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
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:
@@ -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);
|
||||
|
||||
@@ -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]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user