mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
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>
123 lines
5.4 KiB
TypeScript
123 lines
5.4 KiB
TypeScript
/**
|
|
* @fileoverview `GET /api/sessions/:id/git-status`: what the session's workspace has not committed or
|
|
* pushed (src/git-workspace-status.ts), for the bottom-bar Git indicator and its panel. The answer is
|
|
* an overview: the enclosing repository, or each repository found below a folder that holds several
|
|
* projects (see `getGitWorkspaceOverview` for exactly which).
|
|
*
|
|
* Read-only and offline: it never fetches and never runs a git write command. A remote (SSH) or
|
|
* Docker session is not inspected and answers `state: 'unsupported'`, and neither is any repository at or
|
|
* inside a Docker case workspace (a container can write there, and git here would run on the host). Ownership goes through
|
|
* `findSessionOrFail`, like every session-scoped route.
|
|
*/
|
|
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
|
import { redactGitCredentials } from '../../git-clone.js';
|
|
import { readDockerCases } from '../../docker-hosts.js';
|
|
import { getDataDir } from '../../config/instance.js';
|
|
import { findSessionOrFail } from '../route-helpers.js';
|
|
import {
|
|
emptyOverview,
|
|
findWorkspaceRepo,
|
|
getGitFileDiff,
|
|
resolveOverviewLimits,
|
|
getGitWorkspaceOverview,
|
|
getGitWorkspaceStatus,
|
|
type GitFileDiff,
|
|
type GitFileKind,
|
|
type GitRunner,
|
|
type GitWorkspaceOverview,
|
|
} from '../../git-workspace-status.js';
|
|
import type { SessionPort } from '../ports/index.js';
|
|
|
|
/** Host paths of every Docker case workspace: repositories at or inside these are never inspected. */
|
|
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?: 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: timeout !== undefined ? Number(timeout) * 1000 : undefined,
|
|
});
|
|
}
|
|
|
|
export function registerGitStatusRoutes(
|
|
app: FastifyInstance,
|
|
ctx: SessionPort,
|
|
git?: GitRunner,
|
|
dockerWorkspaces: () => Promise<string[]> = defaultDockerWorkspaces
|
|
): 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?: unknown; timeout?: unknown };
|
|
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' }) };
|
|
return {
|
|
success: true,
|
|
data: await getGitWorkspaceOverview(session.workingDir, {
|
|
git,
|
|
fresh: fresh === '1',
|
|
...limits,
|
|
dockerWorkspaces: await dockerWorkspaces(),
|
|
}),
|
|
};
|
|
});
|
|
|
|
// The diff of one file the panel lists. `repo` and `path` are matched against the CURRENT status
|
|
// (a repository this session's folder holds, a path git reported in it) rather than trusted, so
|
|
// the route cannot be pointed at an arbitrary directory or file. The repository is checked against
|
|
// the overview's own (cached) list with the Docker roots as they are now, and only that one
|
|
// 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?: unknown; timeout?: unknown };
|
|
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,
|
|
...limits,
|
|
dockerWorkspaces: await dockerWorkspaces(),
|
|
})
|
|
: null;
|
|
const status = repoRoot
|
|
? await getGitWorkspaceStatus(repoRoot, { git, fresh: true, timeoutMs: limits.timeoutMs })
|
|
: null;
|
|
const entry =
|
|
status?.state === 'ok'
|
|
? status.files.find((f) => f.path === path && f.kind === (kind as GitFileKind))
|
|
: undefined;
|
|
if (!status?.repoRoot || !entry) {
|
|
reply.code(404);
|
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
|
|
}
|
|
try {
|
|
return {
|
|
success: true,
|
|
data: await getGitFileDiff(status.repoRoot, entry, { git, timeoutMs: limits.timeoutMs }),
|
|
};
|
|
} catch (err) {
|
|
reply.code(500);
|
|
return createErrorResponse(
|
|
ApiErrorCode.INTERNAL_ERROR,
|
|
`git diff failed: ${redactGitCredentials(getErrorMessage(err))}`
|
|
);
|
|
}
|
|
});
|
|
}
|