Files
Codeman/src/git-workspace-status.ts
T
DevvynandClaude Sonnet 5.5 0c4bb5169f 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
2026-10-05 11:49:38 +08:00

672 lines
27 KiB
TypeScript

/**
* @fileoverview "What has this session's workspace not committed or pushed?": a read-only git
* snapshot of a session's working directory, for the bottom-bar Git indicator and its panel
* (`GET /api/sessions/:id/git-status`). Agents leave work uncommitted and unpushed; this makes that
* visible without leaving Codeman.
*
* Split so the parts that matter test without a repo:
* - pure: `parsePorcelainV2` (status output → branch, upstream, ahead/behind, per-file entries),
* `parseCommitLog`
* - IO: `getGitWorkspaceStatus` (a handful of async, bounded, read-only `git` calls), with a short
* single-flight cache so several tabs polling one repo cost one set of git processes
*
* WHICH repositories. `getGitWorkspaceOverview` answers for the session's working directory:
* - inside a repository (or at its root): that one repository. git finds it by walking UP, so a
* subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
* to the outer one, and is not scanned;
* - NOT inside one (a folder that holds several projects): every repository found up to two levels
* DOWN (`MAX_REPOS` of them, skipping dot-folders, `node_modules` and the like, never following
* symlinks), each reported separately;
* - a repository that merely sits ABOVE the workspace and is the home folder or higher (a dotfiles
* repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work.
*
* Rules the code keeps and the tests pin:
* - READ-ONLY and OFFLINE. It never fetches, pulls, commits or writes. "Behind" therefore reflects
* the last fetch (the UI says so); "ahead" and the unpushed list are exact against the
* remote-tracking refs already on disk. `--no-optional-locks` keeps `git status` from even
* refreshing the index, so polling cannot contend with the agent's own git commands.
* - Every call is async (`execFile`), bounded by a timeout, and never interpolates a path into a
* shell: the working directory is the process `cwd`, and the only operand-like input is a fixed
* revision range.
* - Output is capped: the counts are exact, the lists are not (`filesTruncated`).
* - git can run helpers a repository configures: a clean filter (`filter.<name>.clean`) still runs
* during `git status` and `git diff`, as it does for any `git status`. A LOCAL session already
* runs as this same OS user, so polling adds no privilege there. What is turned off: the
* filesystem monitor (`core.fsmonitor`), external diff and textconv drivers, and the signature
* program (`log.showSignature`). A repository a container can write to is NOT inspected: a
* Docker session answers `unsupported`, and any 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, because
* the container could have planted that config and git here would run it on the host.
* - Remote URLs and git's stderr can embed `user:token@host`; anything that reaches a client goes
* through `redactGitCredentials`.
*
* @module git-workspace-status
*/
import { execFile } from 'node:child_process';
import { promises as fs } from 'node:fs';
import { homedir } from 'node:os';
import { basename, join, relative, sep } from 'node:path';
import { promisify } from 'node:util';
import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
const execFileAsync = promisify(execFile);
const GIT_TIMEOUT_MS = 10_000;
/** `git status` on a huge tree can print a lot; a bound on what we will hold. */
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
/** Max file rows returned. The counts stay exact. */
export const MAX_FILES = 300;
/** Max unpushed commits listed. The count stays exact. */
export const MAX_COMMITS = 50;
/** A fresh-enough result is reused, so N tabs on one repo cost one set of git calls. */
const CACHE_TTL_MS = 4000;
const CACHE_MAX_ENTRIES = 64;
export type GitFileKind = 'staged' | 'unstaged' | 'untracked' | 'conflicted';
export interface GitFileEntry {
/** Path relative to the repository root, as git reports it. */
path: string;
/** Rename/copy source, when the entry is one. */
origPath?: string;
/** Status letter in the index (`M`, `A`, `D`, `R`, `C`, `T`, `.`). */
index: string;
/** Status letter in the working tree (`M`, `D`, `T`, `.`, ...). `?` for untracked. */
worktree: string;
kind: GitFileKind;
}
export interface GitCommitEntry {
hash: string;
author: string;
/** Seconds since the epoch. */
time: number;
subject: string;
}
export interface GitWorkspaceStatus {
/**
* `ok`: a repository, the rest of the fields are meaningful. `not-a-repo`: nothing to show.
* `unsupported`: a remote or Docker session (never inspected). `error`: git failed; see `error`.
*/
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
error?: string;
repoRoot?: string;
/** Null when HEAD is detached. */
branch: string | null;
detached: boolean;
upstream: string | null;
/** The configured upstream no longer exists on the remote (deleted and pruned): nothing is tracked. */
upstreamGone: boolean;
ahead: number;
/** Behind the remote-tracking ref as of the LAST FETCH; this module never fetches. */
behind: number;
/** Whether the repository has any remote at all. */
hasRemote: boolean;
counts: {
staged: number;
unstaged: number;
untracked: number;
conflicted: number;
/** Distinct paths that are not committed. */
uncommitted: number;
stashes: number;
};
files: GitFileEntry[];
filesTruncated: boolean;
/** Commits on this branch that no remote has: exact. */
unpushedCount: number;
unpushed: GitCommitEntry[];
checkedAt: number;
}
const EMPTY: Omit<GitWorkspaceStatus, 'state' | 'checkedAt'> = {
branch: null,
detached: false,
upstream: null,
upstreamGone: false,
ahead: 0,
behind: 0,
hasRemote: false,
counts: { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 },
files: [],
filesTruncated: false,
unpushedCount: 0,
unpushed: [],
};
export const emptyStatus = (
state: GitWorkspaceStatus['state'],
extra: Partial<GitWorkspaceStatus> = {}
): GitWorkspaceStatus => ({ ...EMPTY, counts: { ...EMPTY.counts }, state, checkedAt: Date.now(), ...extra });
// ---------------------------------------------------------------------------
// Pure parsing
// ---------------------------------------------------------------------------
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). */
upstreamGone: boolean;
ahead: number;
behind: number;
files: GitFileEntry[];
}
/**
* Parse `git status --porcelain=v2 --branch -z`. Entries are NUL-separated and paths are NOT quoted,
* so a name with spaces, quotes or a newline arrives intact. A rename/copy (`2 ...`) is followed by
* one more NUL-terminated token holding the original path.
*/
export function parsePorcelainV2(text: string): ParsedStatus {
const out: ParsedStatus = {
branch: null,
detached: false,
upstream: null,
upstreamGone: false,
ahead: 0,
behind: 0,
files: [],
};
let sawAb = false;
const tokens = text.split('\0');
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
if (!t) continue;
if (t.startsWith('# ')) {
const [key, ...rest] = t.slice(2).split(' ');
const value = rest.join(' ');
if (key === 'branch.head') {
out.detached = value === '(detached)';
out.branch = out.detached ? null : value;
} else if (key === 'branch.upstream') {
out.upstream = value;
} else if (key === 'branch.ab') {
sawAb = true;
const m = /^\+(\d+) -(\d+)$/.exec(value);
if (m) {
out.ahead = Number(m[1]);
out.behind = Number(m[2]);
}
}
continue;
}
const type = t[0];
if (type === '1') {
// 1 XY sub mH mI mW hH hI path
const f = t.split(' ');
const xy = f[1] ?? '..';
out.files.push(...entriesFor(xy, f.slice(8).join(' ')));
} else if (type === '2') {
// 2 XY sub mH mI mW hH hI Xscore path <NUL> origPath
const f = t.split(' ');
const xy = f[1] ?? '..';
const path = f.slice(9).join(' ');
const origPath = tokens[++i] ?? '';
out.files.push(...entriesFor(xy, path, origPath));
} else if (type === 'u') {
// u XY sub m1 m2 m3 mW h1 h2 h3 path
const f = t.split(' ');
out.files.push({
path: f.slice(10).join(' '),
index: f[1]?.[0] ?? 'U',
worktree: f[1]?.[1] ?? 'U',
kind: 'conflicted',
});
} else if (type === '?') {
out.files.push({ path: t.slice(2), index: '?', worktree: '?', kind: 'untracked' });
}
// '!' (ignored) is not requested; anything unknown is skipped rather than guessed at.
}
out.upstreamGone = out.upstream !== null && !sawAb;
return out;
}
/** One porcelain entry can be both staged AND modified in the tree: that is two rows, one per kind. */
function entriesFor(xy: string, path: string, origPath?: string): GitFileEntry[] {
const index = xy[0] ?? '.';
const worktree = xy[1] ?? '.';
const rows: GitFileEntry[] = [];
const base = origPath ? { path, origPath } : { path };
if (index !== '.') rows.push({ ...base, index, worktree, kind: 'staged' });
if (worktree !== '.') rows.push({ ...base, index, worktree, kind: 'unstaged' });
return rows;
}
/** Parse `git log --format=%h%x1f%an%x1f%ct%x1f%s%x1e`. */
export function parseCommitLog(text: string): GitCommitEntry[] {
const out: GitCommitEntry[] = [];
for (const record of text.split('\x1e')) {
const r = record.replace(/^\n+/, '');
if (!r) continue;
const [hash, author, time, ...subject] = r.split('\x1f');
if (!hash) continue;
out.push({ hash, author: author ?? '', time: Number(time) || 0, subject: subject.join('\x1f') });
}
return out;
}
// ---------------------------------------------------------------------------
// IO
// ---------------------------------------------------------------------------
/** Runs `git <args>` in `cwd` and returns stdout. Injected so the cache and error paths test without git. */
export type GitRunner = (cwd: string, args: string[]) => Promise<string>;
export const runGit: GitRunner = async (cwd, args) => {
const { stdout } = await execFileAsync(
'git',
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
// consult a filesystem monitor on behalf of a poll. log.showSignature=false: `git log` must not run
// a configured gpg.program to verify signatures.
['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args],
{
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: MAX_OUTPUT_BYTES,
env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
}
);
return stdout;
};
function describeFailure(err: unknown): { notARepo: boolean; message: string } {
const e = err as { code?: unknown; stderr?: unknown; message?: string };
const stderr = typeof e.stderr === 'string' ? e.stderr : '';
if (/not a git repository/i.test(stderr)) return { notARepo: true, message: '' };
if (e.code === 'ENOENT') return { notARepo: false, message: 'git is not installed (or the folder no longer exists)' };
if (e.code === 'ETIMEDOUT' || (err as { killed?: boolean }).killed)
return { notARepo: false, message: 'git timed out' };
const text = (stderr || e.message || 'git failed').trim().split('\n')[0];
return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
}
async function collect(cwd: string, git: GitRunner): Promise<GitWorkspaceStatus> {
let statusText: string;
try {
statusText = await git(cwd, [
'status',
'--porcelain=v2',
'--branch',
'-z',
'--untracked-files=normal',
'--ignore-submodules=dirty',
]);
} catch (err) {
const f = describeFailure(err);
return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
}
const parsed = parsePorcelainV2(statusText);
const safe = async (args: string[]): Promise<string> => {
try {
return await git(cwd, args);
} catch {
return '';
}
};
// A configured upstream whose remote branch is gone has no `branch.ab`, and `@{upstream}` no longer
// resolves: treat it as no usable upstream rather than letting the failed rev-list read as 0.
const hasUpstream = parsed.upstream !== null && !parsed.upstreamGone;
// With an upstream: what is ahead of it. Without one (a branch never pushed, a detached HEAD, or an
// upstream that is gone): what is on HEAD but on no remote-tracking ref at all.
const range = hasUpstream ? ['@{upstream}..HEAD'] : ['HEAD', '--not', '--remotes'];
const [root, remotes, stash, countText, logText] = await Promise.all([
safe(['rev-parse', '--show-toplevel']),
safe(['remote']),
safe(['stash', 'list', '--format=%gd']),
safe(['rev-list', '--count', ...range]),
safe(['log', `--max-count=${MAX_COMMITS}`, '--format=%h%x1f%an%x1f%ct%x1f%s%x1e', ...range]),
]);
const hasRemote = remotes.trim().length > 0;
// A repository with no remote has nothing to push to, so "unpushed" would be every commit it has.
const unpushedCount = hasUpstream || hasRemote ? Number(countText.trim()) || 0 : 0;
const unpushed = unpushedCount > 0 ? parseCommitLog(logText) : [];
const counts = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 };
const distinct = new Set<string>();
for (const f of parsed.files) {
counts[f.kind]++;
distinct.add(f.path);
}
counts.uncommitted = distinct.size;
counts.stashes = stash.split('\n').filter(Boolean).length;
return {
state: 'ok',
repoRoot: root.trim() || undefined,
branch: parsed.branch,
detached: parsed.detached,
upstream: parsed.upstream,
upstreamGone: parsed.upstreamGone,
ahead: parsed.ahead,
behind: parsed.behind,
hasRemote,
counts,
files: parsed.files.slice(0, MAX_FILES),
filesTruncated: parsed.files.length > MAX_FILES,
unpushedCount,
unpushed,
checkedAt: Date.now(),
};
}
interface CacheEntry {
at: number;
value?: GitWorkspaceStatus;
inflight?: Promise<GitWorkspaceStatus>;
}
const cache = new Map<string, CacheEntry>();
/** For tests. */
export function clearGitStatusCache(): void {
cache.clear();
discoveryCache.clear();
}
/**
* 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.
*/
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;
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);
}
}
return value;
} catch (err) {
cache.delete(cwd);
throw err;
}
}
// ---------------------------------------------------------------------------
// Which repositories: the overview
// ---------------------------------------------------------------------------
/** How far below the working directory to look for repositories (`cwd/a/b` is found, `cwd/a/b/c` is not). */
const DISCOVERY_MAX_DEPTH = 2;
/** Directory entries inspected per folder (after sorting), so a folder with thousands of children stays cheap. */
const DISCOVERY_MAX_ENTRIES = 300;
/** Repositories reported for one workspace. */
export const MAX_REPOS = 12;
/** The list of repositories under a folder changes rarely, so it is re-scanned far less often than status. */
const DISCOVERY_TTL_MS = 30_000;
/** Folders that are never worth descending into when looking for projects. */
const DISCOVERY_SKIP = new Set(['node_modules', 'dist', 'build', 'target', '__pycache__', 'venv', 'vendor']);
/** Status calls in flight at once for one overview: each is several git processes. */
const STATUS_CONCURRENCY = 4;
export interface GitRepoEntry {
/** Folder name of the repository (its root's basename). */
name: string;
/** The repository root relative to the working directory: `.`, `..`, `api`, `apps/web`. */
path: string;
status: GitWorkspaceStatus;
}
export interface GitWorkspaceOverview {
/** `ok` when at least one repository was found; the other states are as in `GitWorkspaceStatus`. */
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
error?: string;
repos: GitRepoEntry[];
/** More than `MAX_REPOS` repositories were found; only the first are reported. */
reposTruncated: boolean;
checkedAt: number;
}
export const emptyOverview = (
state: GitWorkspaceOverview['state'],
extra: Partial<GitWorkspaceOverview> = {}
): GitWorkspaceOverview => ({ state, repos: [], reposTruncated: false, checkedAt: Date.now(), ...extra });
const realOr = async (p: string): Promise<string> => {
try {
return await fs.realpath(p);
} catch {
return p;
}
};
/** Real paths of `dirs` (a Docker case workspace may be reached through a symlink). */
const realAll = (dirs: string[]): Promise<string[]> => Promise.all(dirs.map(realOr));
const isWithin = (child: string, root: string): boolean => child === root || child.startsWith(root + sep);
/**
* True when `path` is, or is inside, any of the (already real) `roots`. Used for Docker case
* workspaces: a container can write there, so git must not run on its behalf on the host.
*/
export async function isInsideAny(path: string, realRoots: string[]): Promise<boolean> {
if (!realRoots.length) return false;
const real = await realOr(path);
return realRoots.some((r) => isWithin(real, r));
}
/**
* True when `repoRoot` is a repository that merely contains the workspace and is the home folder or
* above it (`$HOME` managed as a dotfiles repo, `/`, `/home`): its changes are not the session's work.
* A workspace that IS the repository root is never "unrelated", even when that root is the home folder.
*/
export async function isUnrelatedAncestor(repoRoot: string, cwd: string, home: string): Promise<boolean> {
const [root, here, h] = await Promise.all([realOr(repoRoot), realOr(cwd), realOr(home)]);
if (root === here) return false;
return root === sep || h === root || h.startsWith(root + sep);
}
async function hasDotGit(dir: string): Promise<boolean> {
try {
await fs.lstat(join(dir, '.git')); // a directory, or a file (worktrees and submodules)
return true;
} catch {
return false;
}
}
/** Most directory entries READ from one folder before sorting and slicing, so the scan of a huge folder is bounded. */
const DISCOVERY_MAX_SCAN = 5000;
/** Up to `DISCOVERY_MAX_SCAN` entries of `dir` (null when unreadable). */
async function readDirBounded(dir: string): Promise<import('node:fs').Dirent[] | null> {
let handle;
try {
handle = await fs.opendir(dir);
} catch {
return null;
}
const out: import('node:fs').Dirent[] = [];
try {
for await (const e of handle) {
out.push(e);
if (out.length >= DISCOVERY_MAX_SCAN) break;
}
} catch {
/* a folder that fails mid-read: use what was read */
} finally {
await handle.close().catch(() => {});
}
return out;
}
/** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
export async function discoverChildRepos(
cwd: string,
excludeRealRoots: string[] = []
): Promise<{ dirs: string[]; truncated: boolean }> {
const found: string[] = [];
let level = [cwd];
for (let depth = 1; depth <= DISCOVERY_MAX_DEPTH && level.length > 0; depth++) {
const next: string[] = [];
for (const dir of level) {
const entries = await readDirBounded(dir);
if (!entries) continue;
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
entries.length = Math.min(entries.length, DISCOVERY_MAX_ENTRIES);
for (const e of entries) {
// isDirectory() is false for a symlink, which is how a link to elsewhere is never followed.
if (!e.isDirectory() || e.name.startsWith('.') || DISCOVERY_SKIP.has(e.name)) continue;
const child = join(dir, e.name);
// A Docker case workspace (or anything inside one) is never inspected, nor descended into.
if (await isInsideAny(child, excludeRealRoots)) continue;
if (await hasDotGit(child)) found.push(child);
else next.push(child);
}
}
level = next;
}
return { dirs: found.slice(0, MAX_REPOS), truncated: found.length > MAX_REPOS };
}
const discoveryCache = new Map<string, { at: number; value: { dirs: string[]; truncated: boolean } }>();
/** Run `fn` over `items` with at most `limit` in flight, keeping the input order. */
async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
out[i] = await fn(items[i]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return out;
}
/**
* 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: {
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 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,
};
}
// Not inside a repository of this workspace: look below for projects.
const hit = discoveryCache.get(cwd);
let found: { dirs: string[]; truncated: boolean };
if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS) found = hit.value;
else {
found = await discoverChildRepos(cwd, dockerRoots);
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));
const repos: GitRepoEntry[] = [];
found.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() };
}
// ── Per-file diff ──────────────────────────────────────────────────────────
/** Longest diff handed to the browser; beyond this it is cut at a line boundary and flagged. */
export const MAX_DIFF_BYTES = 400 * 1024;
export interface GitFileDiff {
/** Unified diff text (empty when git reports no textual change, e.g. a mode-only edit shows its header). */
diff: string;
truncated: boolean;
binary: boolean;
}
/** A repo-relative path git reported, minus anything that could escape the repo. (A leading `-` is fine: every operand follows `--`.) */
export function isSafeRepoRelativePath(p: string): boolean {
if (!p || p.length > 4096 || p.includes('\0') || p.startsWith('/')) return false;
return !p.split('/').includes('..');
}
/**
* The diff of one changed file, as the panel's rows describe it: `staged` is index vs HEAD,
* `unstaged`/`conflicted` is working tree vs index (a conflict shows git's combined diff), and
* `untracked` is the whole file as additions. Read-only. `--no-ext-diff --no-textconv` stop the external
* diff and textconv drivers a repository configures; a clean filter still runs, as it does for any
* `git diff`, which is why a container-writable repository never reaches this function.
*/
export async function getGitFileDiff(
repoRoot: string,
file: { path: string; origPath?: string; kind: GitFileKind },
opts: { git?: GitRunner } = {}
): Promise<GitFileDiff> {
if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
throw new Error('Invalid path');
}
const git = opts.git ?? runGit;
const base = ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '-U3'];
let args: string[];
if (file.kind === 'untracked') args = [...base, '--no-index', '--', '/dev/null', file.path];
else {
const paths = file.origPath ? [file.origPath, file.path] : [file.path];
args = file.kind === 'staged' ? [...base, '--cached', '-M', '--', ...paths] : [...base, '--', ...paths];
}
let out: string;
try {
out = await git(repoRoot, args);
} catch (err) {
// `--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;
}
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 };
const cut = out.lastIndexOf('\n', MAX_DIFF_BYTES);
return { diff: out.slice(0, cut > 0 ? cut : MAX_DIFF_BYTES), truncated: true, binary };
}