mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
feat(git-status): configurable max repositories and git timeout; keep unreadable repos listed
Settings (per device): Git status: max repositories (1-50, default 12) and git timeout (5-120 s, default 30, was a fixed 10). Both go to /git-status and /git-diff as maxRepos / timeout query parameters, clamped server-side (an empty value means the default). A repository whose git status fails stays in the list with the reason instead of being dropped silently, shows as '? N' in the indicator, and the truncation line now names the limit and the setting. The discovery cache is keyed by the limit. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
ac94f339ac
commit
3ae22f64a4
@@ -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