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:
Devvyn
2026-10-06 20:04:45 +08:00
co-authored by Claude Sonnet 5.5
parent ac94f339ac
commit 3ae22f64a4
13 changed files with 429 additions and 48 deletions
+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' }));
}