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
+5
View File
@@ -0,0 +1,5 @@
---
"aicodeman": patch
---
The Git status window's limits are settings now. **Git status: max repositories** (App Settings → Header & Panels → Bottom bar, per device, 1 to 50, default 12) is how many repositories it lists when the session's folder holds several projects, and **Git status: git timeout** (5 to 120 seconds, default 30, was a fixed 10) is how long one git command may run. A repository git could not read (a timeout on a slow network share was the usual cause) used to be dropped without a word, which left a count like "first 11" under a limit of 12; it now stays in the list with the reason, shows as `? N` in the indicator instead of letting it read ✓, and the truncation line reads "Showing the first N of more than N repositories" and points at the setting. `GET /api/sessions/:id/git-status` and `/git-diff` take `maxRepos` and `timeout` (seconds) query parameters, clamped server-side, and the overview reports `repoLimit`.
+4 -2
View File
@@ -771,11 +771,13 @@ Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes ou
**Which repositories.** git finds a repository by walking *up* from the session's working directory, so:
- Inside a repository (or at its root): that one repository, whole (a subfolder reports its enclosing repo, `path` says where it is, e.g. `../..`). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it.
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most 12 (`reposTruncated` says when there were more). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s.
- **Not** inside one (a folder that holds several projects): every repository found up to **two levels down**, nearest and alphabetical first, at most `maxRepos` of them (default 12, 1 to 50; `reposTruncated` says when there were more and `repoLimit` is the limit that was applied). Dot-folders, `node_modules`, `dist`, `build`, `target`, `vendor`, `venv` and `__pycache__` are skipped, symlinks are never followed, and a repository's own contents are not searched. The list of repositories is re-scanned at most every 30 s; each repository's status is cached for 4 s.
- A repository that merely sits **above** the workspace and is the home folder or higher (a dotfiles repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. A workspace that *is* that repository's root is not ignored.
- A worktree (whose `.git` is a file) counts as a repository. A submodule's own uncommitted files are not reported, only a changed submodule pointer.
`data` is `{ state, repos, reposTruncated, checkedAt }`:
Both routes accept two optional query parameters, which the UI sends from its per-device settings and the server clamps again: `maxRepos` (1 to 50, default 12) and `timeout` (seconds one git command may run, 5 to 120, default 30). An empty or non-numeric value means the default. A repository whose `git status` fails (typically a timeout on a slow network share) is **kept in `repos[]`** with `status.state: 'error'` and the reason in `status.error`, not dropped, so it is visible that something is not being reported.
`data` is `{ state, repos, reposTruncated, repoLimit, checkedAt }`:
- `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is:
`branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`.
+5 -1
View File
@@ -66,7 +66,11 @@ bottom bar, off by default and per device. It reads `● N` uncommitted files, `
pushed, `⚠ N` merge conflicts, or `✓` when everything is committed and pushed. Click it for the
Git window; see [Working With Files](Working-With-Files#git-changes). **Git status: group files
by folder** (per device, on by default) shows changed files under collapsed folders in that
window; off lists every file by its full path.
window; off lists every file by its full path. **Git status: max repositories** (per device,
1 to 50, default 12) is how many repositories the window lists when a session's folder holds
several projects. **Git status: git timeout** (per device, 5 to 120 seconds, default 30) is how
long one git command may run before that repository is reported as unreadable; raise it for
repositories on a slow network share.
Most default to off. The stock desktop header is system stats, File Viewer, Tiles, and the gear.
**Header Stats Style** picks how the system stats and plan usage are drawn: *Compact*
+4 -1
View File
@@ -187,7 +187,10 @@ Click it for a draggable window, in the style of the File Viewer:
**Open file** jumps to the File Viewer; **Back** returns to the list. A binary file shows a
note instead, and a diff over 400 KB is cut short.
- A session folder that holds several projects gets one collapsible section per repository
found up to two levels down. They all start collapsed (each summary line shows its branch and
found up to two levels down (up to **Git status: max repositories**, 12 by default; the window says
when there are more). A repository git could not read, typically a timeout on a slow network
share, is listed with the reason and counted as `? N` in the bottom-bar indicator, never silently
left out; the **git timeout** setting raises how long it waits. They all start collapsed (each summary line shows its branch and
what is outstanding), and the ones you open stay open when the window refreshes; an unrelated repository above the workspace (a dotfiles repo
in your home folder) is ignored.
+73 -33
View File
@@ -52,7 +52,10 @@ import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
const execFileAsync = promisify(execFile);
const GIT_TIMEOUT_MS = 10_000;
/** How long one git command may run, unless the caller passes `timeoutMs` (a slow network share needs more). */
export const DEFAULT_GIT_TIMEOUT_MS = 30_000;
export const MIN_GIT_TIMEOUT_MS = 5_000;
export const MAX_GIT_TIMEOUT_MS = 120_000;
/** `git status` on a huge tree can print a lot; a bound on what we will hold. */
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
/** Max file rows returned. The counts stay exact. */
@@ -258,9 +261,9 @@ export function parseCommitLog(text: string): GitCommitEntry[] {
// ---------------------------------------------------------------------------
/** Runs `git <args>` in `cwd` and returns stdout. Injected so the cache and error paths test without git. */
export type GitRunner = (cwd: string, args: string[]) => Promise<string>;
export type GitRunner = (cwd: string, args: string[], opts?: { timeoutMs?: number }) => Promise<string>;
export const runGit: GitRunner = async (cwd, args) => {
export const runGit: GitRunner = async (cwd, args, opts) => {
const { stdout } = await execFileAsync(
'git',
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
@@ -269,7 +272,7 @@ export const runGit: GitRunner = async (cwd, args) => {
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
{
cwd,
timeout: GIT_TIMEOUT_MS,
timeout: opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
maxBuffer: MAX_OUTPUT_BYTES,
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
}
@@ -288,17 +291,14 @@ function describeFailure(err: unknown): { notARepo: boolean; message: string } {
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
}
async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus> {
async function collect(cwd: string, git: GitRunner, timeoutMs?: number): Promise<GitWorkspaceStatus> {
let statusText: string;
try {
statusText = await git(cwd, [
'status',
'--porcelain=v2',
'--branch',
'-z',
'--untracked-files=normal',
'--ignore-submodules=dirty',
]);
statusText = await git(
cwd,
['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=normal', '--ignore-submodules=dirty'],
{ timeoutMs }
);
} catch (err) {
const f = describeFailure(err);
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
@@ -307,7 +307,7 @@ async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus>
const safe = async (args: string[]): Promise<string> => {
try {
return await git(cwd, args);
return await git(cwd, args, { timeoutMs });
} catch {
return '';
}
@@ -415,10 +415,12 @@ async function singleFlight<T>(
*/
export async function getGitWorkspaceStatus(
cwd: string,
opts: { git?: GitRunner; now?: () => number; fresh?: boolean } = {}
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number } = {}
): Promise<GitWorkspaceStatus> {
const git = opts.git ?? runGit;
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () => collect(cwd, git));
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () =>
collect(cwd, git, opts.timeoutMs)
);
}
type RepoToplevel = { state: 'ok'; root: string } | { state: 'not-a-repo' } | { state: 'error'; error: string };
@@ -427,12 +429,12 @@ const toplevelCache = new Map<string, CacheEntry<RepoToplevel>>();
/** The root of the repository enclosing `cwd` (git walks up), from one cheap `rev-parse`. Cached like the status. */
function enclosingRepoRoot(
cwd: string,
opts: { git?: GitRunner; now?: () => number; fresh?: boolean }
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number }
): Promise<RepoToplevel> {
const git = opts.git ?? runGit;
return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
try {
const root = (await git(cwd, ['rev-parse', '--show-toplevel'])).trim();
const root = (await git(cwd, ['rev-parse', '--show-toplevel'], { timeoutMs: opts.timeoutMs })).trim();
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
} catch (err) {
const f = describeFailure(err);
@@ -451,6 +453,16 @@ const DISCOVERY_MAX_DEPTH = 2;
const DISCOVERY_MAX_ENTRIES = 300;
/** Repositories reported for one workspace. */
export const MAX_REPOS = 12;
/** The most repositories a caller may ask for: each one costs several git processes per poll. */
export const MAX_REPOS_LIMIT = 50;
/** `value` as a whole number within [min, max], else `fallback`. For options that arrive as untrusted query strings. */
export function clampInt(value: unknown, min: number, max: number, fallback: number): number {
// An empty string is "not given", not 0 (Number('') is 0, which would clamp to the minimum).
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.trunc(n)));
}
/** The list of repositories under a folder changes rarely, so it is re-scanned far less often than status. */
const DISCOVERY_TTL_MS = 30_000;
/** Folders that are never worth descending into when looking for projects. */
@@ -472,8 +484,10 @@ export interface GitWorkspaceOverview {
reason?: 'remote' | 'docker';
error?: string;
repos: GitRepoEntry[];
/** More than `MAX_REPOS` repositories were found; only the first are reported. */
/** More than `repoLimit` repositories were found; only the first are reported. */
reposTruncated: boolean;
/** The most repositories this overview would list (the caller's setting, or `MAX_REPOS`). */
repoLimit?: number;
checkedAt: number;
}
@@ -553,7 +567,8 @@ async function readDirBounded(dir: string): Promise<import('node:fs').Dirent[] |
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
export async function discoverChildRepos(
cwd: string,
excludeRealRoots: string[] = []
excludeRealRoots: string[] = [],
maxRepos: number = MAX_REPOS
): Promise<{ dirs: string[]; truncated: boolean }> {
const found: string[] = [];
let level = [cwd];
@@ -576,7 +591,7 @@ export async function discoverChildRepos(
}
level = next;
}
return { dirs: found.slice(0, MAX_REPOS), truncated: found.length > MAX_REPOS };
return { dirs: found.slice(0, maxRepos), truncated: found.length > maxRepos };
}
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
@@ -602,13 +617,28 @@ export interface GitOverviewOptions {
home?: string;
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
dockerWorkspaces?: string[];
/** How many repositories to report below a folder that is not itself a repository (1 to `MAX_REPOS_LIMIT`, default `MAX_REPOS`). */
maxRepos?: number;
/** How long one git command may run, in ms (`MIN_GIT_TIMEOUT_MS` to `MAX_GIT_TIMEOUT_MS`, default `DEFAULT_GIT_TIMEOUT_MS`). */
timeoutMs?: number;
}
/** The repository limit and git timeout an overview was computed with, from untrusted options. */
export function resolveOverviewLimits(opts: { maxRepos?: unknown; timeoutMs?: unknown }): {
maxRepos: number;
timeoutMs: number;
} {
return {
maxRepos: clampInt(opts.maxRepos, 1, MAX_REPOS_LIMIT, MAX_REPOS),
timeoutMs: clampInt(opts.timeoutMs, MIN_GIT_TIMEOUT_MS, MAX_GIT_TIMEOUT_MS, DEFAULT_GIT_TIMEOUT_MS),
};
}
type WorkspaceRepos =
| { kind: 'docker' }
| { kind: 'error'; error: string }
| { kind: 'enclosing'; root: string }
| { kind: 'children'; dirs: string[]; truncated: boolean };
| { kind: 'children'; dirs: string[]; truncated: boolean; limit: number };
/**
* WHICH repositories belong to the workspace (the module header has the rules), without a full
@@ -617,6 +647,7 @@ type WorkspaceRepos =
*/
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
const now = opts.now ?? Date.now;
const { maxRepos, timeoutMs } = resolveOverviewLimits(opts);
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
// Checked BEFORE any git runs: git walks up from cwd, and a repository the container can write to
// could carry config (a clean filter) that runs on the host.
@@ -624,7 +655,7 @@ async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Pro
// The enclosing repository is identified before its full status runs, so an unrelated one above the
// workspace (a dotfiles repo in $HOME) costs one rev-parse, and its status failing cannot hide the
// repositories below.
const top = await enclosingRepoRoot(cwd, opts);
const top = await enclosingRepoRoot(cwd, { ...opts, timeoutMs });
if (top.state === 'error') return { kind: 'error', error: top.error };
if (top.state === 'ok') {
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
@@ -633,18 +664,20 @@ async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Pro
}
// Not inside a repository of this workspace: look below for projects.
const hit = discoveryCache.get(cwd);
// Keyed by the limit too: a list cut at 12 must not answer a request for 30.
const discoveryKey = `${cwd}\0${maxRepos}`;
const hit = discoveryCache.get(discoveryKey);
let found: { dirs: string[]; truncated: boolean };
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
else {
found = await discoverChildRepos(cwd, dockerRoots);
discoveryCache.set(cwd, { at: now(), value: found });
found = await discoverChildRepos(cwd, dockerRoots, maxRepos);
discoveryCache.set(discoveryKey, { at: now(), value: found });
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
}
// The cached list can predate a Docker case linked since: filter it against the roots as they are NOW.
const dirs: string[] = [];
for (const dir of found.dirs) if (!(await isInsideAny(dir, dockerRoots))) dirs.push(dir);
return { kind: 'children', dirs, truncated: found.truncated };
return { kind: 'children', dirs, truncated: found.truncated, limit: maxRepos };
}
/**
@@ -659,7 +692,7 @@ export async function getGitWorkspaceOverview(
if (where.kind === 'docker') return emptyOverview('unsupported', { reason: 'docker' });
if (where.kind === 'error') return emptyOverview('error', { error: where.error });
if (where.kind === 'enclosing') {
const primary = await getGitWorkspaceStatus(cwd, opts);
const primary = await getGitWorkspaceStatus(cwd, { ...opts, timeoutMs: resolveOverviewLimits(opts).timeoutMs });
if (primary.state === 'error') return emptyOverview('error', { error: primary.error });
if (primary.state !== 'ok') return emptyOverview('not-a-repo');
const root = primary.repoRoot ?? where.root;
@@ -671,14 +704,21 @@ export async function getGitWorkspaceOverview(
};
}
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) => getGitWorkspaceStatus(dir, opts));
const timeoutMs = resolveOverviewLimits(opts).timeoutMs;
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) =>
getGitWorkspaceStatus(dir, { ...opts, timeoutMs })
);
const repos: GitRepoEntry[] = [];
where.dirs.forEach((dir, i) => {
const status = statuses[i];
if (status.state === 'ok') repos.push({ name: basename(dir), path: relative(cwd, dir), status });
// A repository git could not read (a timeout on a slow share, a broken worktree) stays in the
// list with its error, so it is visible that something is not being reported; only a folder
// that turned out not to be a repository after all is left out.
if (status.state === 'ok' || status.state === 'error')
repos.push({ name: basename(dir), path: relative(cwd, dir), status });
});
if (!repos.length) return emptyOverview('not-a-repo');
return { state: 'ok', repos, reposTruncated: where.truncated, checkedAt: Date.now() };
return { state: 'ok', repos, reposTruncated: where.truncated, repoLimit: where.limit, checkedAt: Date.now() };
}
/**
@@ -726,7 +766,7 @@ export function isSafeRepoRelativePath(p: string): boolean {
export async function getGitFileDiff(
repoRoot: string,
file: { path: string; origPath?: string; kind: GitFileKind },
opts: { git?: GitRunner } = {}
opts: { git?: GitRunner; timeoutMs?: number } = {}
): Promise<GitFileDiff> {
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
throw new Error('Invalid path');
@@ -742,7 +782,7 @@ export async function getGitFileDiff(
let out: string;
let cutShort = false;
try {
out = await git(repoRoot, args);
out = await git(repoRoot, args, { timeoutMs: opts.timeoutMs });
} catch (err) {
const e = err as { code?: unknown; stdout?: unknown };
// `--no-index` exits 1 when the files differ, which is the normal case for it.
+49 -5
View File
@@ -117,7 +117,9 @@ Object.assign(CodemanApp.prototype, {
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
this._gitStatusFetchedAt = Date.now();
try {
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status${fresh ? '?fresh=1' : ''}`);
const query = new URLSearchParams(this.gitStatusLimits());
if (fresh) query.set('fresh', '1');
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status?${query}`);
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
this._gitStatus = data ? { sessionId: sid, data } : null;
} catch {
@@ -131,6 +133,23 @@ Object.assign(CodemanApp.prototype, {
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
},
/**
* How many repositories to list below a folder that is not itself a repository, and how long one
* git command may run, in seconds (Settings → Bottom bar, per device). The server clamps both again.
*/
gitStatusLimits() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const num = (v, min, max, fallback) => {
const n = Math.trunc(Number(v));
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
};
return {
maxRepos: num(settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos, 1, 50, 12),
timeout: num(settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds, 5, 120, 30),
};
},
/** Whether the Git window groups changed files under collapsible folders (default on). */
isGitStatusTree() {
const settings = this.loadAppSettingsFromStorage();
@@ -150,13 +169,16 @@ Object.assign(CodemanApp.prototype, {
let uncommitted = 0;
let unpushed = 0;
let conflicted = 0;
let unreadable = 0;
for (const r of overview.repos) {
if (r.status.state === 'error') unreadable += 1;
uncommitted += r.status.counts.uncommitted;
unpushed += r.status.unpushedCount;
conflicted += r.status.counts.conflicted;
}
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 ? 'dirty' : 'clean';
return { uncommitted, unpushed, conflicted, repos: overview.repos.length, tone };
// A repository that could not be read is not "clean": it must not let the indicator say ✓.
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 || unreadable > 0 ? 'dirty' : 'clean';
return { uncommitted, unpushed, conflicted, unreadable, repos: overview.repos.length, tone };
},
/** One sentence for the tooltip and the screen-reader label. */
@@ -168,6 +190,8 @@ Object.assign(CodemanApp.prototype, {
if (sum.conflicted) bits.push(plural(sum.conflicted, 'file with a merge conflict', 'files with merge conflicts'));
if (sum.uncommitted) bits.push(plural(sum.uncommitted, 'uncommitted file', 'uncommitted files'));
if (sum.unpushed) bits.push(plural(sum.unpushed, 'commit not pushed', 'commits not pushed'));
if (sum.unreadable)
bits.push(plural(sum.unreadable, 'repository could not be read', 'repositories could not be read'));
if (!bits.length) bits.push('everything is committed and pushed');
let where;
if (sum.repos > 1) where = `${sum.repos} repositories`;
@@ -196,6 +220,7 @@ Object.assign(CodemanApp.prototype, {
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
if (sum.unreadable) parts.push(`? ${sum.unreadable}`);
if (!parts.length) parts.push('✓');
if (label) label.textContent = parts.join(' ');
const sentence = this._gitStatusSentence(data);
@@ -343,7 +368,11 @@ Object.assign(CodemanApp.prototype, {
for (const r of repos) body.append(this._gitRepoSection(r));
if (overview.reposTruncated) {
body.append(
el('div', 'git-status-more', `Showing the first ${repos.length} repositories found under this folder.`)
el(
'div',
'git-status-more',
`Showing the first ${overview.repoLimit || repos.length} of more than ${overview.repoLimit || repos.length} repositories under this folder. Raise “Git status: max repositories” in Settings → Bottom bar to see more.`
)
);
}
}
@@ -364,6 +393,16 @@ Object.assign(CodemanApp.prototype, {
_gitRepoSection(r) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const d = r.status;
if (d.state === 'error') {
// Kept in the list with the reason, rather than silently left out.
const row = el('div', 'git-status-repo git-status-repo--error');
row.append(el('span', 'git-status-repo-name', r.name));
if (r.path !== r.name) row.append(el('span', 'git-status-repo-path', r.path));
const why = el('span', 'git-status-repo-unreadable', `⚠ could not read: ${d.error || 'git failed'}`);
why.title = 'If this is a timeout, raise “Git status: git timeout” in Settings → Bottom bar.';
row.append(why);
return row;
}
const section = el('details', 'git-status-repo');
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
@@ -582,7 +621,12 @@ Object.assign(CodemanApp.prototype, {
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
this._gitDiffView = view;
this._renderGitStatusPanel();
const qs = new URLSearchParams({ repo: repoRoot, path: file.path, kind: file.kind });
const qs = new URLSearchParams({
repo: repoRoot,
path: file.path,
kind: file.kind,
...this.gitStatusLimits(),
});
const res = await this._api(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff?${qs}`);
// Back, another file or another session while this was in flight: drop the answer.
if (this._gitDiffView !== view) return;
+14
View File
@@ -2009,6 +2009,20 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitStatusTree" checked><span class="slider"></span></label>
</div>
<div class="set-row" data-search="git status maximum repositories folder many repos limit">
<div class="set-row-text">
<span class="set-row-label">Git status: max repositories <span class="set-scope">device</span></span>
<span class="set-row-desc">When the session's folder holds several projects instead of being one, the Git window lists up to this many (1 to 50, default 12). Each one costs a few git commands per refresh.</span>
</div>
<input type="number" id="appSettingsGitStatusMaxRepos" class="set-num" value="12" min="1" max="50" step="1">
</div>
<div class="set-row" data-search="git status timeout seconds slow share network">
<div class="set-row-text">
<span class="set-row-label">Git status: git timeout <span class="set-scope">device</span></span>
<span class="set-row-desc">Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.</span>
</div>
<input type="number" id="appSettingsGitStatusTimeout" class="set-num" value="30" min="5" max="120" step="5">
</div>
</div>
</div>
+8 -1
View File
@@ -455,6 +455,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
document.getElementById('appSettingsShowGitStatus').checked = settings.showGitStatus ?? defaults.showGitStatus ?? false;
document.getElementById('appSettingsGitStatusTree').checked = settings.gitStatusTree ?? defaults.gitStatusTree ?? true;
document.getElementById('appSettingsGitStatusMaxRepos').value = settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos ?? 12;
document.getElementById('appSettingsGitStatusTimeout').value = settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds ?? 30;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
@@ -2552,6 +2554,9 @@ Object.assign(CodemanApp.prototype, {
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
showGitStatus: document.getElementById('appSettingsShowGitStatus').checked,
gitStatusTree: document.getElementById('appSettingsGitStatusTree').checked,
// Clamped here and again on the server; an empty or odd value falls back to the default.
gitStatusMaxRepos: Math.min(50, Math.max(1, parseInt(document.getElementById('appSettingsGitStatusMaxRepos').value, 10) || 12)),
gitStatusTimeoutSeconds: Math.min(120, Math.max(5, parseInt(document.getElementById('appSettingsGitStatusTimeout').value, 10) || 30)),
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
@@ -2823,6 +2828,8 @@ Object.assign(CodemanApp.prototype, {
// Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT.
showGitStatus: _sgs,
gitStatusTree: _gst,
gitStatusMaxRepos: _gsm,
gitStatusTimeoutSeconds: _gst2,
showTabDetachButton: _tdb,
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
mobileOverviewEnabled: _mov,
@@ -4228,7 +4235,7 @@ Object.assign(CodemanApp.prototype, {
'language',
'terminalWheelLocalScrollback',
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
'showTabDetachButton',
'mobileOverviewEnabled',
'sessionLineageLines',
+16
View File
@@ -20586,6 +20586,22 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
border-left: 1px solid var(--border);
}
/* Git status panel: a repository git could not read stays in the list, with the reason */
.git-status-repo--error {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0.35rem 0.5rem;
background: var(--bg-input);
min-width: 0;
}
.git-status-repo-unreadable {
color: #e0a030;
font-size: 0.68rem;
}
/* ============================================================
Tab grouping by state (tabArrangement: 'state', opt-in;
Discussion #426 option C)
+32 -5
View File
@@ -20,6 +20,7 @@ import {
emptyOverview,
findWorkspaceRepo,
getGitFileDiff,
resolveOverviewLimits,
getGitWorkspaceOverview,
getGitWorkspaceStatus,
type GitFileDiff,
@@ -33,6 +34,18 @@ import type { SessionPort } from '../ports/index.js';
const defaultDockerWorkspaces = async (): Promise<string[]> =>
(await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean);
/**
* The repository limit and git timeout a request asks for (Settings → Bottom bar, per device):
* `maxRepos` and `timeout` (seconds) query parameters, each clamped to a safe range, so an odd value
* can never cost more than the module's own ceiling.
*/
function limitsFrom(query: { maxRepos?: string; timeout?: string }) {
return resolveOverviewLimits({
maxRepos: query.maxRepos,
timeoutMs: query.timeout?.trim() ? Number(query.timeout) * 1000 : undefined, // '' and absent mean "default"
});
}
export function registerGitStatusRoutes(
app: FastifyInstance,
ctx: SessionPort,
@@ -41,7 +54,9 @@ export function registerGitStatusRoutes(
): void {
app.get('/api/sessions/:id/git-status', async (req): Promise<ApiResponse<GitWorkspaceOverview>> => {
const { id } = req.params as { id: string };
const { fresh } = req.query as { fresh?: string };
const query = req.query as { fresh?: string; maxRepos?: string; timeout?: string };
const { fresh } = query;
const limits = limitsFrom(query);
const session = findSessionOrFail(ctx, id, req);
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
@@ -50,6 +65,7 @@ export function registerGitStatusRoutes(
data: await getGitWorkspaceOverview(session.workingDir, {
git,
fresh: fresh === '1',
...limits,
dockerWorkspaces: await dockerWorkspaces(),
}),
};
@@ -62,16 +78,24 @@ export function registerGitStatusRoutes(
// repository's status is refreshed: a click must not re-read every repository in the folder.
app.get('/api/sessions/:id/git-diff', async (req, reply): Promise<ApiResponse<GitFileDiff>> => {
const { id } = req.params as { id: string };
const { repo, path, kind } = req.query as { repo?: string; path?: string; kind?: string };
const query = req.query as { repo?: string; path?: string; kind?: string; maxRepos?: string; timeout?: string };
const { repo, path, kind } = query;
const limits = limitsFrom(query);
const session = findSessionOrFail(ctx, id, req);
if (session.remote || session.docker) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
}
const repoRoot = repo
? await findWorkspaceRepo(session.workingDir, repo, { git, dockerWorkspaces: await dockerWorkspaces() })
? await findWorkspaceRepo(session.workingDir, repo, {
git,
...limits,
dockerWorkspaces: await dockerWorkspaces(),
})
: null;
const status = repoRoot
? await getGitWorkspaceStatus(repoRoot, { git, fresh: true, timeoutMs: limits.timeoutMs })
: null;
const status = repoRoot ? await getGitWorkspaceStatus(repoRoot, { git, fresh: true }) : null;
const entry =
status?.state === 'ok'
? status.files.find((f) => f.path === path && f.kind === (kind as GitFileKind))
@@ -81,7 +105,10 @@ export function registerGitStatusRoutes(
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
}
try {
return { success: true, data: await getGitFileDiff(status.repoRoot, entry, { git }) };
return {
success: true,
data: await getGitFileDiff(status.repoRoot, entry, { git, timeoutMs: limits.timeoutMs }),
};
} catch (err) {
reply.code(500);
return createErrorResponse(
+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' }));
}