fix(git-status): landing fixes (#537)

- A cached list of repositories below a folder is re-checked against the
  Docker case workspaces as they are now, so a repository linked as a Docker
  workspace within the 30 s list cache is no longer inspected.
- A diff past runGit's 8 MB output bound is cut short from git's partial
  output instead of failing with a 500.
- The browser test waits for its slow route handler on unroute
  (unrouteAll behavior 'wait'), so a late route.continue() cannot fail the run.
- "Upstream is gone" now reads "Upstream not on remote", true for a branch
  that was never pushed as well as one deleted on the remote; docs mirrored.
- The diff route checks the repository against the workspace's own cached
  repository list (findWorkspaceRepo) and refreshes only that repository,
  instead of a fresh status of every repository in the folder.
- CLAUDE.md: a Key Patterns entry for the git read surface and its rules.
- The enclosing repository is identified with one cached rev-parse before
  any full status, so an unrelated repository above the workspace costs one
  process and its failure no longer hides the repositories below.
- Wiki: the bottom-bar indicator moves out of the header-controls table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-05 19:51:59 +02:00
parent cf26853390
commit 2c38e77f8a
10 changed files with 283 additions and 83 deletions
+2
View File
@@ -302,6 +302,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
+1 -1
View File
@@ -717,7 +717,7 @@ Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes ou
## Git status
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch`. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream was deleted and pruned on the remote, `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
`GET /api/sessions/:id/git-status` is what the bottom-bar Git indicator and its panel read (Settings → Header & Panels → Bottom bar, per-device, default off). It reports what the session's workspace has not committed or pushed. **Read-only and offline:** it never fetches, pulls, commits or writes (it runs `git status` with `--no-optional-locks`, so it does not even refresh the index), which is why `behind` is as of the last `git fetch`. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`). A repository whose root is, or is inside, a Docker case workspace is dropped (from the walk-up, the scan below a folder, and the diff route): a container can write there, and a repository's own clean filter or signature program would run on the host. When a branch's upstream does not exist on the remote (deleted and pruned, or never pushed, as after cloning an empty repository and committing), `upstreamGone` is `true` and the unpushed list falls back to commits on no remote-tracking ref at all.
`GET /api/sessions/:id/git-diff?repo=<repoRoot>&path=<path>&kind=staged|unstaged|untracked|conflicted` returns the unified diff of one file the panel lists (`{ diff, truncated, binary }`; staged is index vs HEAD, unstaged is working tree vs index, untracked is the whole file as additions). It is what opens when you click a file in the Git panel. `repo` and `path` are matched against the current status rather than trusted, so anything the status does not list is `404`. Read-only: it passes `--no-ext-diff --no-textconv` (no external diff or textconv driver runs), but a repository's clean filters still run, as they do for any `git diff`, which is why a repository a container can write to is never inspected (below). Capped at 400 KB, and refused (`400`) for remote and Docker sessions; a repository at or inside a Docker case workspace is not in the status, so it is `404` here.
+7 -1
View File
@@ -119,12 +119,18 @@ The right side of the header. Almost all of these are off until you enable them
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
| Git status (bottom bar) | Off | Right of the bottom bar: uncommitted and unpushed work for the active session. Click it for the Git window. See [Working With Files](Working-With-Files#git-changes). |
| Admin panel | Multi-user only | User administration. |
New header controls never appear on phones. Phone layout is deliberately minimal and is
covered in [Mobile Guide](Mobile-Guide).
## Bottom bar
**Git status** sits at the right of the bottom bar and shows the active session's uncommitted
and unpushed work. It is off by default and per device: turn it on in **App Settings → Header &
Panels → Bottom bar**. Click it for the Git window. See
[Working With Files](Working-With-Files#git-changes).
## Connection state
The dot in the header is the quick read. Two louder surfaces exist because a cached page
+2 -2
View File
@@ -175,8 +175,8 @@ Click it for a draggable window, in the style of the File Viewer:
- **Uncommitted changes**, grouped as staged, not staged, untracked and conflicted, each with a
status letter (`M` modified, `A` added, `D` deleted, `R` renamed, `?` new, `U` conflict).
- **Not pushed**: the commits no remote has. A branch with no upstream says so, and so does one whose
upstream was deleted on the remote ("Upstream is gone"), which counts every commit on no remote
rather than showing a green tick.
upstream does not exist on the remote, because it was never pushed or was deleted there ("Upstream
not on remote"), which counts every commit on no remote rather than showing a green tick.
- Files are grouped under their folders, collapsed until you click a folder (a chain of single-child
folders is one row, and the folders you opened stay open when the list refreshes). Turn off
**App Settings → Header & Panels → Bottom bar → Git status: group files by folder** for a flat
+152 -62
View File
@@ -98,7 +98,10 @@ export interface GitWorkspaceStatus {
branch: string | null;
detached: boolean;
upstream: string | null;
/** The configured upstream no longer exists on the remote (deleted and pruned): nothing is tracked. */
/**
* The configured upstream does not exist on the remote (deleted and pruned, or never pushed, as after
* cloning an empty repository and committing): nothing is tracked.
*/
upstreamGone: boolean;
ahead: number;
/** Behind the remote-tracking ref as of the LAST FETCH; this module never fetches. */
@@ -150,7 +153,7 @@ export interface ParsedStatus {
branch: string | null;
detached: boolean;
upstream: string | null;
/** `# branch.upstream` was printed but `# branch.ab` was not: the remote branch is gone (deleted and pruned). */
/** `# branch.upstream` was printed but `# branch.ab` was not: no such remote branch (deleted and pruned, or never pushed). */
upstreamGone: boolean;
ahead: number;
behind: number;
@@ -357,51 +360,85 @@ async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus>
};
}
interface CacheEntry {
interface CacheEntry<T> {
at: number;
value?: GitWorkspaceStatus;
inflight?: Promise<GitWorkspaceStatus>;
value?: T;
inflight?: Promise<T>;
}
const cache = new Map<string, CacheEntry>();
const cache = new Map<string, CacheEntry<GitWorkspaceStatus>>();
/** For tests. */
export function clearGitStatusCache(): void {
cache.clear();
toplevelCache.clear();
discoveryCache.clear();
}
/**
* `compute()` for `key`, single-flight and briefly cached: concurrent callers share the computation in
* flight, and a result younger than `CACHE_TTL_MS` is reused. `fresh` skips the reuse (a person pressed
* Refresh and expects the truth) but still joins a computation that is already running, which is as
* current as a new one would be.
*/
async function singleFlight<T>(
map: Map<string, CacheEntry<T>>,
key: string,
opts: { now: () => number; fresh?: boolean },
compute: () => Promise<T>
): Promise<T> {
const hit = map.get(key);
if (hit?.inflight) return hit.inflight;
if (!opts.fresh && hit?.value !== undefined && opts.now() - hit.at < CACHE_TTL_MS) return hit.value;
const inflight = compute();
map.set(key, { at: opts.now(), inflight });
try {
const value = await inflight;
map.set(key, { at: opts.now(), value });
if (map.size > CACHE_MAX_ENTRIES) {
for (const [k, v] of map) {
if (map.size <= CACHE_MAX_ENTRIES) break;
if (k !== key && !v.inflight) map.delete(k);
}
}
return value;
} catch (err) {
map.delete(key);
throw err;
}
}
/**
* The git snapshot of `cwd`. Concurrent callers share one in-flight computation, and a result younger
* than a few seconds is reused, so several tabs polling one repo cost one set of git processes.
* `fresh` skips the reuse (a person pressed Refresh and expects the truth) but still joins a
* computation that is already running, which is as current as a new one would be.
* `fresh` skips the reuse but still joins a computation already running (see `singleFlight`).
*/
export async function getGitWorkspaceStatus(
cwd: string,
opts: { git?: GitRunner; now?: () => number; fresh?: boolean } = {}
): Promise<GitWorkspaceStatus> {
const git = opts.git ?? runGit;
const now = opts.now ?? Date.now;
const hit = cache.get(cwd);
if (hit?.inflight) return hit.inflight;
if (!opts.fresh && hit?.value && now() - hit.at < CACHE_TTL_MS) return hit.value;
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () => collect(cwd, git));
}
const inflight = collect(cwd, git);
cache.set(cwd, { at: now(), inflight });
try {
const value = await inflight;
cache.set(cwd, { at: now(), value });
if (cache.size > CACHE_MAX_ENTRIES) {
for (const [k, v] of cache) {
if (cache.size <= CACHE_MAX_ENTRIES) break;
if (k !== cwd && !v.inflight) cache.delete(k);
}
type RepoToplevel = { state: 'ok'; root: string } | { state: 'not-a-repo' } | { state: 'error'; error: string };
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 }
): 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();
return root ? { state: 'ok', root } : { state: 'not-a-repo' };
} catch (err) {
const f = describeFailure(err);
return f.notARepo ? { state: 'not-a-repo' } : { state: 'error', error: f.message };
}
return value;
} catch (err) {
cache.delete(cwd);
throw err;
}
});
}
// ---------------------------------------------------------------------------
@@ -558,41 +595,41 @@ async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Prom
return out;
}
export interface GitOverviewOptions {
git?: GitRunner;
now?: () => number;
fresh?: boolean;
home?: string;
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
dockerWorkspaces?: string[];
}
type WorkspaceRepos =
| { kind: 'docker' }
| { kind: 'error'; error: string }
| { kind: 'enclosing'; root: string }
| { kind: 'children'; dirs: string[]; truncated: boolean };
/**
* Everything git knows about the session's workspace: the enclosing repository when there is one,
* otherwise each repository found below the working directory. See the module header for the rules.
* WHICH repositories belong to the workspace (the module header has the rules), without a full
* status of any of them: one cached `rev-parse` for the enclosing repository, else the cached scan
* below the folder. The overview and the diff route both go through here, so they cannot disagree.
*/
export async function getGitWorkspaceOverview(
cwd: string,
opts: {
git?: GitRunner;
now?: () => number;
fresh?: boolean;
home?: string;
/** Docker case workspaces (host paths): repositories at or inside these are never inspected. */
dockerWorkspaces?: string[];
} = {}
): Promise<GitWorkspaceOverview> {
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
const now = opts.now ?? Date.now;
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.
if (await isInsideAny(cwd, dockerRoots)) return emptyOverview('unsupported', { reason: 'docker' });
const primary = await getGitWorkspaceStatus(cwd, opts);
if (primary.state === 'error') return emptyOverview('error', { error: primary.error });
const home = opts.home ?? homedir();
if (primary.repoRoot && (await isInsideAny(primary.repoRoot, dockerRoots))) {
return emptyOverview('unsupported', { reason: 'docker' });
}
if (primary.state === 'ok' && !(primary.repoRoot && (await isUnrelatedAncestor(primary.repoRoot, cwd, home)))) {
const root = primary.repoRoot ?? cwd;
return {
state: 'ok',
repos: [{ name: basename(root), path: relative(cwd, root) || '.', status: primary }],
reposTruncated: false,
checkedAt: primary.checkedAt,
};
if (await isInsideAny(cwd, dockerRoots)) return { kind: 'docker' };
// 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);
if (top.state === 'error') return { kind: 'error', error: top.error };
if (top.state === 'ok') {
if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
if (!(await isUnrelatedAncestor(top.root, cwd, opts.home ?? homedir())))
return { kind: 'enclosing', root: top.root };
}
// Not inside a repository of this workspace: look below for projects.
@@ -604,14 +641,61 @@ export async function getGitWorkspaceOverview(
discoveryCache.set(cwd, { at: now(), value: found });
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
}
const statuses = await mapLimited(found.dirs, STATUS_CONCURRENCY, (dir) => getGitWorkspaceStatus(dir, opts));
// 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 };
}
/**
* Everything git knows about the session's workspace: the enclosing repository when there is one,
* otherwise each repository found below the working directory. See the module header for the rules.
*/
export async function getGitWorkspaceOverview(
cwd: string,
opts: GitOverviewOptions = {}
): Promise<GitWorkspaceOverview> {
const where = await resolveWorkspaceRepos(cwd, opts);
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);
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;
return {
state: 'ok',
repos: [{ name: basename(root), path: relative(cwd, root) || '.', status: primary }],
reposTruncated: false,
checkedAt: primary.checkedAt,
};
}
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) => getGitWorkspaceStatus(dir, opts));
const repos: GitRepoEntry[] = [];
found.dirs.forEach((dir, i) => {
where.dirs.forEach((dir, i) => {
const status = statuses[i];
if (status.state === 'ok') repos.push({ name: basename(dir), path: relative(cwd, dir), status });
});
if (!repos.length) return emptyOverview('not-a-repo');
return { state: 'ok', repos, reposTruncated: found.truncated, checkedAt: Date.now() };
return { state: 'ok', repos, reposTruncated: where.truncated, checkedAt: Date.now() };
}
/**
* `repo` when it is the root of one of the repositories the overview reports for `cwd` (the same rules
* and caches, and the Docker roots as they are now), else null. The diff route checks a requested
* repository with this rather than recomputing every repository's status.
*/
export async function findWorkspaceRepo(
cwd: string,
repo: string,
opts: GitOverviewOptions = {}
): Promise<string | null> {
const where = await resolveWorkspaceRepos(cwd, opts);
const roots = where.kind === 'enclosing' ? [where.root] : where.kind === 'children' ? where.dirs : [];
// git reports a repository root with symlinks resolved; a discovered folder may be reached through one.
for (const root of roots) if (root === repo || (await realOr(root)) === repo) return repo;
return null;
}
// ── Per-file diff ──────────────────────────────────────────────────────────
@@ -656,16 +740,22 @@ export async function getGitFileDiff(
args = file.kind === 'staged' ? [...base, '--cached', '-M', '--', ...paths] : [...base, '--', ...paths];
}
let out: string;
let cutShort = false;
try {
out = await git(repoRoot, args);
} 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.
const e = err as { code?: number; stdout?: unknown };
if (file.kind === 'untracked' && e.code === 1 && typeof e.stdout === 'string') out = e.stdout;
else throw err;
// A diff past runGit's output bound: git was stopped, and what it printed so far is cut below like
// any oversized diff.
else if (e.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' && typeof e.stdout === 'string') {
out = e.stdout;
cutShort = true;
} else throw err;
}
const binary = /^Binary files .* differ$/m.test(out) || /^GIT binary patch$/m.test(out);
if (out.length <= MAX_DIFF_BYTES) return { diff: out, truncated: false, binary };
if (out.length <= MAX_DIFF_BYTES) return { diff: out, truncated: cutShort, binary };
const cut = out.lastIndexOf('\n', MAX_DIFF_BYTES);
return { diff: out.slice(0, cut > 0 ? cut : MAX_DIFF_BYTES), truncated: true, binary };
}
+4 -3
View File
@@ -399,8 +399,9 @@ Object.assign(CodemanApp.prototype, {
const line = el('div', 'git-status-branchline');
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.';
const gone = el('span', 'git-status-chip git-status-chip--warn', 'Upstream not on remote');
gone.title =
'The upstream branch does not exist on the remote (never pushed, or 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}`));
@@ -473,7 +474,7 @@ Object.assign(CodemanApp.prototype, {
'div',
'git-status-note',
data.upstreamGone
? 'The upstream branch is gone from the remote, so these commits are on no remote.'
? 'The upstream branch does not exist on the remote (never pushed, or deleted), so these commits are on no remote.'
: 'This branch has no upstream, so these commits are on no remote yet.'
)
);
+13 -8
View File
@@ -18,8 +18,10 @@ import { getDataDir } from '../../config/instance.js';
import { findSessionOrFail } from '../route-helpers.js';
import {
emptyOverview,
findWorkspaceRepo,
getGitFileDiff,
getGitWorkspaceOverview,
getGitWorkspaceStatus,
type GitFileDiff,
type GitFileKind,
type GitRunner,
@@ -55,7 +57,9 @@ export function registerGitStatusRoutes(
// 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 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 { repo, path, kind } = req.query as { repo?: string; path?: string; kind?: string };
@@ -64,13 +68,14 @@ export function registerGitStatusRoutes(
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,
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));
const repoRoot = repo
? await findWorkspaceRepo(session.workingDir, repo, { git, dockerWorkspaces: await dockerWorkspaces() })
: 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))
: undefined;
if (!status?.repoRoot || !entry) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more');
+3 -1
View File
@@ -389,7 +389,9 @@ describe('Git status indicator in a real browser', () => {
await page.waitForFunction(() => !!(window as any).app._currentGitStatus());
expect(await page.evaluate(() => (window as any).app._gitStatusInFlight)).toBe(false);
page.setDefaultTimeout(30000);
await page.unroute('**/api/sessions/*/git-status*');
// The first read may still be asleep in the handler: wait for it to continue, or its late
// route.continue() lands after the route is gone and fails the run as an unhandled rejection.
await page.unrouteAll({ behavior: 'wait' });
expect(await buttonVisible()).toBe(true);
// Turning the setting off closed the panel; reopen it for the tests that follow.
await page.click('#gitStatusBtn');
+66
View File
@@ -10,6 +10,7 @@ import {
MAX_FILES,
parseCommitLog,
parsePorcelainV2,
runGit,
type GitRunner,
} from '../src/git-workspace-status.js';
@@ -591,6 +592,23 @@ describe('getGitWorkspaceOverview', () => {
expect(names(some)).toEqual(['real-project']);
});
it('identifies an unrelated repository above the workspace before its status runs, so a failure there cannot hide the repositories below', async () => {
repoAt(home);
const ws = join(home, 'case');
repoAt(join(ws, 'proj'));
const calls: Array<[string, string]> = [];
const spy: GitRunner = async (cwd, args) => {
calls.push([cwd, args[0]]);
if (cwd === ws && args[0] === 'status') throw Object.assign(new Error('timed out'), { killed: true });
return runGit(cwd, args);
};
const o = await getGitWorkspaceOverview(ws, { home, git: spy });
expect(o.state).toBe('ok');
expect(names(o)).toEqual(['proj']);
// The dotfiles repository costs one rev-parse, never a full status.
expect(calls.filter(([cwd]) => cwd === ws).map(([, verb]) => verb)).toEqual(['rev-parse']);
});
it('ignores a repository above the home folder (including a repo at the very top)', async () => {
repoAt(top); // contains `home`, so it is above it
const ws = join(home, 'case');
@@ -752,6 +770,30 @@ describe('getGitFileDiff', () => {
expect(cut.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
expect(cut.diff.endsWith('x')).toBe(true);
});
it('cuts a diff that overflowed the output bound instead of failing it', async () => {
const partial = ('+' + 'y'.repeat(99) + '\n').repeat(Math.ceil(MAX_DIFF_BYTES / 100) * 2);
const git = vi.fn(async () => {
throw Object.assign(new RangeError('stdout maxBuffer length exceeded'), {
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
stdout: partial,
});
});
for (const kind of ['unstaged', 'untracked'] as const) {
const d = await getGitFileDiff('/r', { path: 'huge.txt', kind }, { git });
expect(d.truncated).toBe(true);
expect(d.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
expect(d.diff.endsWith('y')).toBe(true);
}
});
it('a real untracked file past the output bound comes back cut short, not as an error', async () => {
writeFileSync(join(repo, 'huge.txt'), ('z'.repeat(99) + '\n').repeat(100_000)); // ~10 MB, over runGit's 8 MB
const d = await getGitFileDiff(repo, { path: 'huge.txt', kind: 'untracked' });
expect(d.truncated).toBe(true);
expect(d.diff.startsWith('diff --git')).toBe(true);
expect(d.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
}, 30_000);
});
describe('Docker case workspaces are never inspected', () => {
@@ -812,6 +854,30 @@ describe('Docker case workspaces are never inspected', () => {
expect(existsSync(join(dock, 'RAN'))).toBe(false);
});
it('re-checks a cached list of repositories against the Docker workspaces as they are now', async () => {
let t = 1_000_000;
const ws = join(home, 'case');
repoAt(join(ws, 'a'));
repoAt(join(ws, 'b'));
const cwds: string[] = [];
const spy: GitRunner = (cwd, args) => {
cwds.push(cwd);
return runGit(cwd, args);
};
const first = await getGitWorkspaceOverview(ws, { home, git: spy, now: () => t });
expect(first.repos.map((r) => r.path)).toEqual(['a', 'b']);
t += 5000; // past the status cache, well inside the list's 30 s
cwds.length = 0;
const o = await getGitWorkspaceOverview(ws, {
home,
git: spy,
now: () => t,
dockerWorkspaces: [join(ws, 'a')],
});
expect(o.repos.map((r) => r.path)).toEqual(['b']);
expect(cwds).not.toContain(join(ws, 'a'));
});
it('sees through a symlink to the workspace', async () => {
const dock = booby(join(home, 'dock'));
const ws = join(home, 'case');
+33 -5
View File
@@ -10,7 +10,7 @@ import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js';
import { clearGitStatusCache, type GitRunner } from '../../src/git-workspace-status.js';
import { clearGitStatusCache, runGit, type GitRunner } from '../../src/git-workspace-status.js';
const ENV = {
...process.env,
@@ -101,14 +101,17 @@ describe('GET /api/sessions/:id/git-status', () => {
});
it('reuses a recent result for a poll, and recomputes for ?fresh=1', async () => {
const runner = vi.fn<GitRunner>(async () => '');
// The workspace is the root of its repository, as real git would say.
const runner = vi.fn<GitRunner>(async (cwd, args) => (args[0] === 'rev-parse' ? `${cwd}\n` : ''));
const { app } = await setup({ git: runner });
const statusCalls = () => runner.mock.calls.filter(([, args]) => args[0] === 'status').length;
const calls = (verb: string) => runner.mock.calls.filter(([, args]) => args[0] === verb).length;
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
const revParses = calls('rev-parse');
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(statusCalls()).toBe(1);
expect(calls('status')).toBe(1);
expect(calls('rev-parse')).toBe(revParses); // the enclosing repository is reused too
await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status?fresh=1' });
expect(statusCalls()).toBe(2);
expect(calls('status')).toBe(2);
});
it('runs git in the session working directory', async () => {
@@ -270,3 +273,28 @@ describe('GET /api/sessions/:id/git-diff', () => {
expect(runner).not.toHaveBeenCalled();
});
});
describe('GET /api/sessions/:id/git-diff in a folder of several repositories', () => {
it('checks the repository against the listed ones and re-reads only that one', async () => {
for (const name of ['api', 'web']) {
const r = join(dir, name);
mkdirSync(r);
git(r, 'init', '-q', '-b', 'main');
writeFileSync(join(r, 'f.txt'), `${name}\n`);
}
const calls: Array<[string, string]> = [];
const runner: GitRunner = async (cwd, args) => {
calls.push([cwd, args[0]]);
return runGit(cwd, args);
};
const { app } = await setup({ git: runner });
const overview = (await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' })).json().data;
const api = overview.repos.find((r: { name: string }) => r.name === 'api').status.repoRoot;
calls.length = 0;
const q = new URLSearchParams({ repo: api, path: 'f.txt', kind: 'untracked' });
const res = await app.inject({ method: 'GET', url: `/api/sessions/test-session-1/git-diff?${q}` });
expect(res.statusCode).toBe(200);
expect(res.json().data.diff).toContain('+api');
expect(calls.filter(([, verb]) => verb === 'status').map(([cwd]) => cwd)).toEqual([api]);
});
});