fix(git-status): address #537 review (docker workspaces, gone upstream, in-flight reset, docs)

- never inspect a repository at or inside a Docker case workspace (walk-up, scan, diff route): git would run its clean filters on the host
- a branch whose upstream was deleted and pruned reports upstreamGone and falls back to commits on no remote, instead of green
- turning the setting off during a poll releases the in-flight flag
- log.showSignature=false; reword the docs: clean filters still run
- CLAUDE.md frontend load order, changeset names git-diff
- discovery reads a bounded, sorted directory listing; leading-dash paths allowed; diff 500 redacts credentials
- keyboard focus survives the poll re-render; panel stays on screen on narrow viewports; aria-expanded visible on light skins

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-05 11:49:38 +08:00
co-authored by Claude Sonnet 5.5
parent b2423c90ce
commit 0c4bb5169f
11 changed files with 352 additions and 49 deletions
+29 -4
View File
@@ -75,6 +75,9 @@ Object.assign(CodemanApp.prototype, {
if (!on) {
this._gitStatus = null;
this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1; // an in-flight read must not repaint
// That read's `finally` no longer owns the flag (its epoch is stale), so release it here: left set,
// turning the setting back on would skip every refresh for this session until a reload.
this._gitStatusInFlight = false;
this.closeGitStatusPanel();
}
this._renderGitStatusButton();
@@ -285,6 +288,10 @@ Object.assign(CodemanApp.prototype, {
if (!body) return;
const overview = this._currentGitStatus();
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
// The 15 s poll replaces every row: put keyboard focus back on the same file afterwards.
const focusKey = body.contains(document.activeElement)
? document.activeElement.closest?.('[data-git-key]')?.dataset.gitKey
: null;
const view = this._gitDiffView;
if (view && view.sessionId === this.activeSessionId) {
// A file's diff is on screen: the 15 s poll re-renders the panel, and must not throw it away.
@@ -316,7 +323,9 @@ Object.assign(CodemanApp.prototype, {
overview.state === 'not-a-repo'
? 'No git repository here: this session’s folder is not one, and none was found inside it (up to two levels down).'
: overview.state === 'unsupported'
? `Git status is not available for ${overview.reason === 'docker' ? 'Docker' : 'remote (SSH)'} sessions.`
? overview.reason === 'docker'
? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
: 'Git status is not available for remote (SSH) sessions.'
: `Could not read the repository: ${overview.error || 'git failed'}`;
body.append(el('div', 'git-status-empty', why));
clearChrome();
@@ -342,6 +351,10 @@ Object.assign(CodemanApp.prototype, {
if (foot) {
foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only: Codeman never fetches or changes the repository, so “behind” is as of your last fetch.`;
}
if (focusKey) {
const again = [...body.querySelectorAll('[data-git-key]')].find((n) => n.dataset.gitKey === focusKey);
again?.focus({ preventScroll: true });
}
},
/**
@@ -384,7 +397,12 @@ Object.assign(CodemanApp.prototype, {
// Branch / upstream line.
const line = el('div', 'git-status-branchline');
if (data.upstream) {
if (data.upstream && data.upstreamGone) {
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
const gone = el('span', 'git-status-chip git-status-chip--warn', 'Upstream is gone');
gone.title = 'The remote branch was deleted (and pruned), so the commits below are on no remote.';
line.append(gone);
} else if (data.upstream) {
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
if (data.ahead) line.append(el('span', 'git-status-chip git-status-chip--warn', `↑ ${data.ahead} ahead`));
if (data.behind) {
@@ -449,9 +467,15 @@ Object.assign(CodemanApp.prototype, {
)
);
} else {
if (!data.upstream) {
if (!data.upstream || data.upstreamGone) {
pushSection.append(
el('div', 'git-status-note', 'This branch has no upstream, so these commits are on no remote yet.')
el(
'div',
'git-status-note',
data.upstreamGone
? 'The upstream branch is gone from the remote, so these commits are on no remote.'
: 'This branch has no upstream, so these commits are on no remote yet.'
)
);
}
for (const c of data.unpushed) pushSection.append(this._gitCommitRow(c));
@@ -523,6 +547,7 @@ Object.assign(CodemanApp.prototype, {
f.kind === 'untracked' ? '?' : f.kind === 'conflicted' ? 'U' : f.kind === 'staged' ? f.index : f.worktree;
const badge = el('span', `git-status-badge git-status-badge--${letter === '?' ? 'new' : letter}`, letter);
badge.title = GIT_STATUS_BADGE_TITLE[letter] || letter;
row.dataset.gitKey = `${f.kind}|${f.path}`;
row.append(badge);
const name = el('span', 'git-status-path', displayName ?? f.path);
if (displayName) name.title = f.path;
+5 -3
View File
@@ -19316,16 +19316,18 @@ html .toolbar .btn-git-status.git-status--conflict {
border-color: rgba(229, 83, 75, 0.5);
}
.btn-git-status[aria-expanded='true'] {
background: rgba(255, 255, 255, 0.12);
html .toolbar .btn-git-status[aria-expanded='true'] {
background: color-mix(in srgb, currentColor 16%, transparent);
}
/* Same look as the Files window. Default spot is just left of it so both can be open. */
.git-status-panel {
position: fixed;
top: calc(var(--header-height) + 10px);
right: 320px;
/* Just left of the Files window; on a narrow viewport slide right so the left edge never leaves the screen. */
right: clamp(8px, calc(100vw - 388px), 320px);
width: 380px;
max-width: calc(100vw - 16px);
height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px);
height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px);
max-height: 600px;
+32 -6
View File
@@ -5,13 +5,16 @@
* 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'`: a Docker workspace is writable
* from inside the sandbox, and git here would run on the host. Ownership goes through
* 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,
@@ -24,14 +27,30 @@ import {
} from '../../git-workspace-status.js';
import type { SessionPort } from '../ports/index.js';
export function registerGitStatusRoutes(app: FastifyInstance, ctx: SessionPort, git?: GitRunner): void {
/** 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);
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 { fresh } = req.query as { fresh?: string };
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' }) };
return {
success: true,
data: await getGitWorkspaceOverview(session.workingDir, {
git,
fresh: fresh === '1',
dockerWorkspaces: await dockerWorkspaces(),
}),
};
});
// The diff of one file the panel lists. `repo` and `path` are matched against the CURRENT status
@@ -45,7 +64,11 @@ export function registerGitStatusRoutes(app: FastifyInstance, ctx: SessionPort,
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
}
const overview = await getGitWorkspaceOverview(session.workingDir, { git, fresh: true });
const overview = await getGitWorkspaceOverview(session.workingDir, {
git,
fresh: true,
dockerWorkspaces: await dockerWorkspaces(),
});
const status = overview.repos.find((r) => r.status.repoRoot === repo)?.status;
const entry = status?.files.find((f) => f.path === path && f.kind === (kind as GitFileKind));
if (!status?.repoRoot || !entry) {
@@ -56,7 +79,10 @@ export function registerGitStatusRoutes(app: FastifyInstance, ctx: SessionPort,
return { success: true, data: await getGitFileDiff(status.repoRoot, entry, { git }) };
} catch (err) {
reply.code(500);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, `git diff failed: ${getErrorMessage(err)}`);
return createErrorResponse(
ApiErrorCode.INTERNAL_ERROR,
`git diff failed: ${redactGitCredentials(getErrorMessage(err))}`
);
}
});
}