mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
fix(git-status): #543 landing fixes
A lone repository git could not read rendered as a clean, empty one. The panel took its single-repository view whenever the overview held one row, and the error row only exists in the list view, so it showed "Nothing uncommitted / No remote configured" with an empty header while the indicator said "? 1". The single-repository view now needs a readable repository and an untruncated overview; anything else takes the list view (headed "1 repository"), and the tooltip names the unreadable repository instead of saying "no branch". The same condition covers a limit of 1 in a folder of several projects, now that max repositories can go down to 1: the one row shown keeps the "Showing the first" notice instead of looking like the only repository. A repeated timeout query parameter reaches the route as an array, and calling trim() on it answered 500 with an internal message, before the ownership check. The route now treats a non-string timeout as "default", like an empty or absent one, and the route test pins it. The browser test gains the lone-unreadable-repository case (error row, no "Nothing uncommitted", "? 1", tooltip names the repository) and the truncated single-row case. Both fail against the unfixed panel. The git timeout input steps by 1, not 5: the save accepts any whole number of seconds and step 5 flagged values like 7 as invalid. Docs: api-reference says repoLimit is only present in the folder-of-projects case, the Settings Reference and Working With Files glyph lists mention "? N", and the module header says the repository count is the caller's maxRepos. The PR's own changeset is removed; its text goes into the single combined release changeset. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"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`.
|
||||
@@ -777,7 +777,7 @@ Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes ou
|
||||
|
||||
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 }`:
|
||||
`data` is `{ state, repos, reposTruncated, repoLimit, checkedAt }` (`repoLimit` in the folder-of-projects case only):
|
||||
|
||||
- `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`.
|
||||
|
||||
@@ -63,8 +63,9 @@ Ultracode Windows, Cron.
|
||||
|
||||
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
|
||||
bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not
|
||||
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
|
||||
pushed, `⚠ N` merge conflicts, `? N` repositories git could not read, 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. **Git status: max repositories** (per device,
|
||||
1 to 50, default 12) is how many repositories the window lists when a session's folder holds
|
||||
|
||||
@@ -168,7 +168,7 @@ surface as an artifact attachment rather than a path you have to go and find.
|
||||
Agents often leave work uncommitted or unpushed. Turn on **App Settings → Header & Panels →
|
||||
Bottom bar → Git status** (per device, off by default) and the right of the bottom bar shows
|
||||
the active session's repository: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge
|
||||
conflicts, `✓` when everything is committed and pushed.
|
||||
conflicts, `? 1` a repository git could not read, `✓` when everything is committed and pushed.
|
||||
|
||||
Click it for a draggable window, in the style of the File Viewer:
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
* subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
|
||||
* to the outer one, and is not scanned;
|
||||
* - NOT inside one (a folder that holds several projects): every repository found up to two levels
|
||||
* DOWN (`MAX_REPOS` of them, skipping dot-folders, `node_modules` and the like, never following
|
||||
* symlinks), each reported separately;
|
||||
* DOWN (the caller's `maxRepos` of them, `MAX_REPOS` by default, skipping dot-folders, `node_modules`
|
||||
* and the like, never following symlinks), each reported separately;
|
||||
* - 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.
|
||||
*
|
||||
|
||||
@@ -197,7 +197,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (sum.repos > 1) where = `${sum.repos} repositories`;
|
||||
else {
|
||||
const d = overview.repos[0].status;
|
||||
where = d.detached ? 'detached HEAD' : d.branch || 'no branch';
|
||||
where = d.state === 'error' ? overview.repos[0].name : d.detached ? 'detached HEAD' : d.branch || 'no branch';
|
||||
}
|
||||
return `Git (${where}): ${bits.join(', ')}. Click for details.`;
|
||||
},
|
||||
@@ -358,13 +358,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const repos = overview.repos;
|
||||
if (repos.length === 1) {
|
||||
// One repository: the panel is that repository, as it always was.
|
||||
if (repos.length === 1 && repos[0].status.state !== 'error' && !overview.reposTruncated) {
|
||||
// One repository: the panel is that repository, as it always was. One that git could not read,
|
||||
// or the only one shown of several (the limit is 1), takes the list view below instead, so its
|
||||
// error row or the "Showing the first" notice is not lost.
|
||||
const d = repos[0].status;
|
||||
if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || '';
|
||||
this._renderGitRepoInto(body, d);
|
||||
} else {
|
||||
if (head) head.textContent = `${repos.length} repositories`;
|
||||
if (head) head.textContent = repos.length === 1 ? '1 repository' : `${repos.length} repositories`;
|
||||
for (const r of repos) body.append(this._gitRepoSection(r));
|
||||
if (overview.reposTruncated) {
|
||||
body.append(
|
||||
|
||||
@@ -2021,7 +2021,7 @@
|
||||
<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">
|
||||
<input type="number" id="appSettingsGitStatusTimeout" class="set-num" value="30" min="5" max="120" step="1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,10 +39,12 @@ const defaultDockerWorkspaces = async (): Promise<string[]> =>
|
||||
* `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 }) {
|
||||
function limitsFrom(query: { maxRepos?: unknown; timeout?: unknown }) {
|
||||
// A repeated key arrives as an array: it is not a number, so it means "default" like '' and absent.
|
||||
const timeout = typeof query.timeout === 'string' && query.timeout.trim() ? query.timeout : undefined;
|
||||
return resolveOverviewLimits({
|
||||
maxRepos: query.maxRepos,
|
||||
timeoutMs: query.timeout?.trim() ? Number(query.timeout) * 1000 : undefined, // '' and absent mean "default"
|
||||
timeoutMs: timeout !== undefined ? Number(timeout) * 1000 : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,7 +56,7 @@ export function registerGitStatusRoutes(
|
||||
): void {
|
||||
app.get('/api/sessions/:id/git-status', async (req): Promise<ApiResponse<GitWorkspaceOverview>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const query = req.query as { fresh?: string; maxRepos?: string; timeout?: string };
|
||||
const query = req.query as { fresh?: string; maxRepos?: unknown; timeout?: unknown };
|
||||
const { fresh } = query;
|
||||
const limits = limitsFrom(query);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
@@ -78,7 +80,7 @@ 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 query = req.query as { repo?: string; path?: string; kind?: string; maxRepos?: string; timeout?: string };
|
||||
const query = req.query as { repo?: string; path?: string; kind?: string; maxRepos?: unknown; timeout?: unknown };
|
||||
const { repo, path, kind } = query;
|
||||
const limits = limitsFrom(query);
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
@@ -469,6 +469,47 @@ describe('Git status indicator in a real browser', () => {
|
||||
expect(await label()).toContain('? 1');
|
||||
expect(await label()).not.toContain('✓');
|
||||
expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/1 repository could not be read/);
|
||||
|
||||
const mockOverview = async (data: Record<string, unknown>) => {
|
||||
await page.unroute('**/api/sessions/*/git-status*');
|
||||
await page.route('**/api/sessions/*/git-status*', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true, data: { state: 'ok', checkedAt: Date.now(), ...data } }),
|
||||
})
|
||||
);
|
||||
await refresh();
|
||||
};
|
||||
// The ONLY repository git could not read: still the error row, never a clean, empty repository.
|
||||
await mockOverview({
|
||||
reposTruncated: false,
|
||||
repoLimit: 12,
|
||||
repos: [
|
||||
{
|
||||
name: 'slow',
|
||||
path: 'slow',
|
||||
status: status({ state: 'error', error: 'git timed out', branch: null, hasRemote: false, upstream: null }),
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.waitForFunction(() => document.getElementById('gitStatusBranch')?.textContent === '1 repository');
|
||||
const lone = (await page.textContent('#gitStatusBody')) ?? '';
|
||||
expect(lone).toContain('could not read: git timed out');
|
||||
expect(lone).not.toContain('Nothing uncommitted');
|
||||
expect(await label()).toContain('? 1');
|
||||
expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/Git \(slow\)/);
|
||||
|
||||
// A limit of 1 in a folder of several: the one row shown keeps the "Showing the first" notice.
|
||||
await mockOverview({
|
||||
reposTruncated: true,
|
||||
repoLimit: 1,
|
||||
repos: [{ name: 'fast', path: 'fast', status: status({ repoRoot: '/x/fast' }) }],
|
||||
});
|
||||
await page.waitForFunction(() =>
|
||||
/Showing the first 1 /.test(document.getElementById('gitStatusBody')?.textContent ?? '')
|
||||
);
|
||||
expect(await page.textContent('#gitStatusBranch')).toBe('1 repository');
|
||||
|
||||
await page.unroute('**/api/sessions/*/git-status*');
|
||||
await setLimits('12', '30');
|
||||
await refresh();
|
||||
|
||||
@@ -339,6 +339,15 @@ describe('GET /api/sessions/:id/git-status limits', () => {
|
||||
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
|
||||
|
||||
// A repeated key reaches the route as an array: it means "default", never a 500.
|
||||
clearGitStatusCache();
|
||||
const repeated = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sessions/test-session-1/git-status?timeout=5&timeout=6&fresh=1',
|
||||
});
|
||||
expect(repeated.statusCode).toBe(200);
|
||||
expect(seen.at(-1)).toBe(30_000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user