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) **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) **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) **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 ## 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. `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. | | 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. | | 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. | | 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. | | Admin panel | Multi-user only | User administration. |
New header controls never appear on phones. Phone layout is deliberately minimal and is New header controls never appear on phones. Phone layout is deliberately minimal and is
covered in [Mobile Guide](Mobile-Guide). 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 ## Connection state
The dot in the header is the quick read. Two louder surfaces exist because a cached page 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 - **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). 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 - **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 upstream does not exist on the remote, because it was never pushed or was deleted there ("Upstream
rather than showing a green tick. 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 - 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 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 **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; branch: string | null;
detached: boolean; detached: boolean;
upstream: string | null; 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; upstreamGone: boolean;
ahead: number; ahead: number;
/** Behind the remote-tracking ref as of the LAST FETCH; this module never fetches. */ /** Behind the remote-tracking ref as of the LAST FETCH; this module never fetches. */
@@ -150,7 +153,7 @@ export interface ParsedStatus {
branch: string | null; branch: string | null;
detached: boolean; detached: boolean;
upstream: string | null; 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; upstreamGone: boolean;
ahead: number; ahead: number;
behind: number; behind: number;
@@ -357,51 +360,85 @@ async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus>
}; };
} }
interface CacheEntry { interface CacheEntry<T> {
at: number; at: number;
value?: GitWorkspaceStatus; value?: T;
inflight?: Promise<GitWorkspaceStatus>; inflight?: Promise<T>;
} }
const cache = new Map<string, CacheEntry>(); const cache = new Map<string, CacheEntry<GitWorkspaceStatus>>();
/** For tests. */ /** For tests. */
export function clearGitStatusCache(): void { export function clearGitStatusCache(): void {
cache.clear(); cache.clear();
toplevelCache.clear();
discoveryCache.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 * 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. * 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 * `fresh` skips the reuse but still joins a computation already running (see `singleFlight`).
* computation that is already running, which is as current as a new one would be.
*/ */
export async function getGitWorkspaceStatus( export async function getGitWorkspaceStatus(
cwd: string, cwd: string,
opts: { git?: GitRunner; now?: () => number; fresh?: boolean } = {} opts: { git?: GitRunner; now?: () => number; fresh?: boolean } = {}
): Promise<GitWorkspaceStatus> { ): Promise<GitWorkspaceStatus> {
const git = opts.git ?? runGit; const git = opts.git ?? runGit;
const now = opts.now ?? Date.now; return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () => collect(cwd, git));
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;
const inflight = collect(cwd, git); type RepoToplevel = { state: 'ok'; root: string } | { state: 'not-a-repo' } | { state: 'error'; error: string };
cache.set(cwd, { at: now(), inflight }); const toplevelCache = new Map<string, CacheEntry<RepoToplevel>>();
try {
const value = await inflight; /** The root of the repository enclosing `cwd` (git walks up), from one cheap `rev-parse`. Cached like the status. */
cache.set(cwd, { at: now(), value }); function enclosingRepoRoot(
if (cache.size > CACHE_MAX_ENTRIES) { cwd: string,
for (const [k, v] of cache) { opts: { git?: GitRunner; now?: () => number; fresh?: boolean }
if (cache.size <= CACHE_MAX_ENTRIES) break; ): Promise<RepoToplevel> {
if (k !== cwd && !v.inflight) cache.delete(k); 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; 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, * WHICH repositories belong to the workspace (the module header has the rules), without a full
* otherwise each repository found below the working directory. See the module header for the rules. * 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( async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
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> {
const now = opts.now ?? Date.now; const now = opts.now ?? Date.now;
const dockerRoots = await realAll(opts.dockerWorkspaces ?? []); const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
// Checked BEFORE any git runs: git walks up from cwd, and a repository the container can write to // 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. // could carry config (a clean filter) that runs on the host.
if (await isInsideAny(cwd, dockerRoots)) return emptyOverview('unsupported', { reason: 'docker' }); if (await isInsideAny(cwd, dockerRoots)) return { kind: 'docker' };
const primary = await getGitWorkspaceStatus(cwd, opts); // The enclosing repository is identified before its full status runs, so an unrelated one above the
if (primary.state === 'error') return emptyOverview('error', { error: primary.error }); // workspace (a dotfiles repo in $HOME) costs one rev-parse, and its status failing cannot hide the
// repositories below.
const home = opts.home ?? homedir(); const top = await enclosingRepoRoot(cwd, opts);
if (primary.repoRoot && (await isInsideAny(primary.repoRoot, dockerRoots))) { if (top.state === 'error') return { kind: 'error', error: top.error };
return emptyOverview('unsupported', { reason: 'docker' }); if (top.state === 'ok') {
} if (await isInsideAny(top.root, dockerRoots)) return { kind: 'docker' };
if (primary.state === 'ok' && !(primary.repoRoot && (await isUnrelatedAncestor(primary.repoRoot, cwd, home)))) { if (!(await isUnrelatedAncestor(top.root, cwd, opts.home ?? homedir())))
const root = primary.repoRoot ?? cwd; return { kind: 'enclosing', root: top.root };
return {
state: 'ok',
repos: [{ name: basename(root), path: relative(cwd, root) || '.', status: primary }],
reposTruncated: false,
checkedAt: primary.checkedAt,
};
} }
// Not inside a repository of this workspace: look below for projects. // 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 }); discoveryCache.set(cwd, { at: now(), value: found });
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string); 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[] = []; const repos: GitRepoEntry[] = [];
found.dirs.forEach((dir, i) => { where.dirs.forEach((dir, i) => {
const status = statuses[i]; const status = statuses[i];
if (status.state === 'ok') repos.push({ name: basename(dir), path: relative(cwd, dir), status }); if (status.state === 'ok') repos.push({ name: basename(dir), path: relative(cwd, dir), status });
}); });
if (!repos.length) return emptyOverview('not-a-repo'); 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 ────────────────────────────────────────────────────────── // ── Per-file diff ──────────────────────────────────────────────────────────
@@ -656,16 +740,22 @@ export async function getGitFileDiff(
args = file.kind === 'staged' ? [...base, '--cached', '-M', '--', ...paths] : [...base, '--', ...paths]; args = file.kind === 'staged' ? [...base, '--cached', '-M', '--', ...paths] : [...base, '--', ...paths];
} }
let out: string; let out: string;
let cutShort = false;
try { try {
out = await git(repoRoot, args); out = await git(repoRoot, args);
} catch (err) { } 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. // `--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; 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); 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); const cut = out.lastIndexOf('\n', MAX_DIFF_BYTES);
return { diff: out.slice(0, cut > 0 ? cut : MAX_DIFF_BYTES), truncated: true, binary }; 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'); const line = el('div', 'git-status-branchline');
if (data.upstream && data.upstreamGone) { if (data.upstream && data.upstreamGone) {
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`)); 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'); const gone = el('span', 'git-status-chip git-status-chip--warn', 'Upstream not on remote');
gone.title = 'The remote branch was deleted (and pruned), so the commits below are on no 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); line.append(gone);
} else if (data.upstream) { } else if (data.upstream) {
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`)); line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
@@ -473,7 +474,7 @@ Object.assign(CodemanApp.prototype, {
'div', 'div',
'git-status-note', 'git-status-note',
data.upstreamGone 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.' : '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 { findSessionOrFail } from '../route-helpers.js';
import { import {
emptyOverview, emptyOverview,
findWorkspaceRepo,
getGitFileDiff, getGitFileDiff,
getGitWorkspaceOverview, getGitWorkspaceOverview,
getGitWorkspaceStatus,
type GitFileDiff, type GitFileDiff,
type GitFileKind, type GitFileKind,
type GitRunner, 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 // 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 // (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>> => { app.get('/api/sessions/:id/git-diff', async (req, reply): Promise<ApiResponse<GitFileDiff>> => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const { repo, path, kind } = req.query as { repo?: string; path?: string; kind?: string }; const { repo, path, kind } = req.query as { repo?: string; path?: string; kind?: string };
@@ -64,13 +68,14 @@ export function registerGitStatusRoutes(
reply.code(400); reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions'); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
} }
const overview = await getGitWorkspaceOverview(session.workingDir, { const repoRoot = repo
git, ? await findWorkspaceRepo(session.workingDir, repo, { git, dockerWorkspaces: await dockerWorkspaces() })
fresh: true, : null;
dockerWorkspaces: await dockerWorkspaces(), const status = repoRoot ? await getGitWorkspaceStatus(repoRoot, { git, fresh: true }) : null;
}); const entry =
const status = overview.repos.find((r) => r.status.repoRoot === repo)?.status; status?.state === 'ok'
const entry = status?.files.find((f) => f.path === path && f.kind === (kind as GitFileKind)); ? status.files.find((f) => f.path === path && f.kind === (kind as GitFileKind))
: undefined;
if (!status?.repoRoot || !entry) { if (!status?.repoRoot || !entry) {
reply.code(404); reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'That file has no outstanding change any more'); 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()); await page.waitForFunction(() => !!(window as any).app._currentGitStatus());
expect(await page.evaluate(() => (window as any).app._gitStatusInFlight)).toBe(false); expect(await page.evaluate(() => (window as any).app._gitStatusInFlight)).toBe(false);
page.setDefaultTimeout(30000); 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); expect(await buttonVisible()).toBe(true);
// Turning the setting off closed the panel; reopen it for the tests that follow. // Turning the setting off closed the panel; reopen it for the tests that follow.
await page.click('#gitStatusBtn'); await page.click('#gitStatusBtn');
+66
View File
@@ -10,6 +10,7 @@ import {
MAX_FILES, MAX_FILES,
parseCommitLog, parseCommitLog,
parsePorcelainV2, parsePorcelainV2,
runGit,
type GitRunner, type GitRunner,
} from '../src/git-workspace-status.js'; } from '../src/git-workspace-status.js';
@@ -591,6 +592,23 @@ describe('getGitWorkspaceOverview', () => {
expect(names(some)).toEqual(['real-project']); 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 () => { 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 repoAt(top); // contains `home`, so it is above it
const ws = join(home, 'case'); const ws = join(home, 'case');
@@ -752,6 +770,30 @@ describe('getGitFileDiff', () => {
expect(cut.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES); expect(cut.diff.length).toBeLessThanOrEqual(MAX_DIFF_BYTES);
expect(cut.diff.endsWith('x')).toBe(true); 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', () => { 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); 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 () => { it('sees through a symlink to the workspace', async () => {
const dock = booby(join(home, 'dock')); const dock = booby(join(home, 'dock'));
const ws = join(home, 'case'); 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness } from './_route-test-utils.js'; import { createRouteTestHarness } from './_route-test-utils.js';
import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.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 = { const ENV = {
...process.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 () => { 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 { 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' }); 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' }); 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' }); 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 () => { it('runs git in the session working directory', async () => {
@@ -270,3 +273,28 @@ describe('GET /api/sessions/:id/git-diff', () => {
expect(runner).not.toHaveBeenCalled(); 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]);
});
});