mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
A lone repository git could not read rendered as a clean, empty one. The panel took its single-repository view whenever the overview held one row, and the error row only exists in the list view, so it showed "Nothing uncommitted / No remote configured" with an empty header while the indicator said "? 1". The single-repository view now needs a readable repository and an untruncated overview; anything else takes the list view (headed "1 repository"), and the tooltip names the unreadable repository instead of saying "no branch". The same condition covers a limit of 1 in a folder of several projects, now that max repositories can go down to 1: the one row shown keeps the "Showing the first" notice instead of looking like the only repository. A repeated timeout query parameter reaches the route as an array, and calling trim() on it answered 500 with an internal message, before the ownership check. The route now treats a non-string timeout as "default", like an empty or absent one, and the route test pins it. The browser test gains the lone-unreadable-repository case (error row, no "Nothing uncommitted", "? 1", tooltip names the repository) and the truncated single-row case. Both fail against the unfixed panel. The git timeout input steps by 1, not 5: the save accepts any whole number of seconds and step 5 flagged values like 7 as invalid. Docs: api-reference says repoLimit is only present in the folder-of-projects case, the Settings Reference and Working With Files glyph lists mention "? N", and the module header says the repository count is the caller's maxRepos. The PR's own changeset is removed; its text goes into the single combined release changeset. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
802 lines
34 KiB
TypeScript
802 lines
34 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 (the caller's `maxRepos` of them, `MAX_REPOS` by default, 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);
|
|
|
|
/** How long one git command may run, unless the caller passes `timeoutMs` (a slow network share needs more). */
|
|
export const DEFAULT_GIT_TIMEOUT_MS = 30_000;
|
|
export const MIN_GIT_TIMEOUT_MS = 5_000;
|
|
export const MAX_GIT_TIMEOUT_MS = 120_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 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. */
|
|
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: no such remote branch (deleted and pruned, or never pushed). */
|
|
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[], opts?: { timeoutMs?: number }) => Promise<string>;
|
|
|
|
export const runGit: GitRunner = async (cwd, args, opts) => {
|
|
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: opts?.timeoutMs ?? DEFAULT_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, timeoutMs?: number): Promise<GitWorkspaceStatus> {
|
|
let statusText: string;
|
|
try {
|
|
statusText = await git(
|
|
cwd,
|
|
['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=normal', '--ignore-submodules=dirty'],
|
|
{ timeoutMs }
|
|
);
|
|
} 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, { timeoutMs });
|
|
} 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<T> {
|
|
at: number;
|
|
value?: T;
|
|
inflight?: Promise<T>;
|
|
}
|
|
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 but still joins a computation already running (see `singleFlight`).
|
|
*/
|
|
export async function getGitWorkspaceStatus(
|
|
cwd: string,
|
|
opts: { git?: GitRunner; now?: () => number; fresh?: boolean; timeoutMs?: number } = {}
|
|
): Promise<GitWorkspaceStatus> {
|
|
const git = opts.git ?? runGit;
|
|
return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () =>
|
|
collect(cwd, git, opts.timeoutMs)
|
|
);
|
|
}
|
|
|
|
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; timeoutMs?: number }
|
|
): 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'], { timeoutMs: opts.timeoutMs })).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 };
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 most repositories a caller may ask for: each one costs several git processes per poll. */
|
|
export const MAX_REPOS_LIMIT = 50;
|
|
|
|
/** `value` as a whole number within [min, max], else `fallback`. For options that arrive as untrusted query strings. */
|
|
export function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
|
// An empty string is "not given", not 0 (Number('') is 0, which would clamp to the minimum).
|
|
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
|
|
if (!Number.isFinite(n)) return fallback;
|
|
return Math.min(max, Math.max(min, Math.trunc(n)));
|
|
}
|
|
/** 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 `repoLimit` repositories were found; only the first are reported. */
|
|
reposTruncated: boolean;
|
|
/** The most repositories this overview would list (the caller's setting, or `MAX_REPOS`). */
|
|
repoLimit?: number;
|
|
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[] = [],
|
|
maxRepos: number = MAX_REPOS
|
|
): 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, maxRepos), truncated: found.length > maxRepos };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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[];
|
|
/** How many repositories to report below a folder that is not itself a repository (1 to `MAX_REPOS_LIMIT`, default `MAX_REPOS`). */
|
|
maxRepos?: number;
|
|
/** How long one git command may run, in ms (`MIN_GIT_TIMEOUT_MS` to `MAX_GIT_TIMEOUT_MS`, default `DEFAULT_GIT_TIMEOUT_MS`). */
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
/** The repository limit and git timeout an overview was computed with, from untrusted options. */
|
|
export function resolveOverviewLimits(opts: { maxRepos?: unknown; timeoutMs?: unknown }): {
|
|
maxRepos: number;
|
|
timeoutMs: number;
|
|
} {
|
|
return {
|
|
maxRepos: clampInt(opts.maxRepos, 1, MAX_REPOS_LIMIT, MAX_REPOS),
|
|
timeoutMs: clampInt(opts.timeoutMs, MIN_GIT_TIMEOUT_MS, MAX_GIT_TIMEOUT_MS, DEFAULT_GIT_TIMEOUT_MS),
|
|
};
|
|
}
|
|
|
|
type WorkspaceRepos =
|
|
| { kind: 'docker' }
|
|
| { kind: 'error'; error: string }
|
|
| { kind: 'enclosing'; root: string }
|
|
| { kind: 'children'; dirs: string[]; truncated: boolean; limit: number };
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async function resolveWorkspaceRepos(cwd: string, opts: GitOverviewOptions): Promise<WorkspaceRepos> {
|
|
const now = opts.now ?? Date.now;
|
|
const { maxRepos, timeoutMs } = resolveOverviewLimits(opts);
|
|
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 { 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, timeoutMs });
|
|
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.
|
|
// Keyed by the limit too: a list cut at 12 must not answer a request for 30.
|
|
const discoveryKey = `${cwd}\0${maxRepos}`;
|
|
const hit = discoveryCache.get(discoveryKey);
|
|
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, maxRepos);
|
|
discoveryCache.set(discoveryKey, { at: now(), value: found });
|
|
if (discoveryCache.size > CACHE_MAX_ENTRIES) discoveryCache.delete(discoveryCache.keys().next().value as string);
|
|
}
|
|
// 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, limit: maxRepos };
|
|
}
|
|
|
|
/**
|
|
* 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, timeoutMs: resolveOverviewLimits(opts).timeoutMs });
|
|
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 timeoutMs = resolveOverviewLimits(opts).timeoutMs;
|
|
const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) =>
|
|
getGitWorkspaceStatus(dir, { ...opts, timeoutMs })
|
|
);
|
|
const repos: GitRepoEntry[] = [];
|
|
where.dirs.forEach((dir, i) => {
|
|
const status = statuses[i];
|
|
// A repository git could not read (a timeout on a slow share, a broken worktree) stays in the
|
|
// list with its error, so it is visible that something is not being reported; only a folder
|
|
// that turned out not to be a repository after all is left out.
|
|
if (status.state === 'ok' || status.state === 'error')
|
|
repos.push({ name: basename(dir), path: relative(cwd, dir), status });
|
|
});
|
|
if (!repos.length) return emptyOverview('not-a-repo');
|
|
return { state: 'ok', repos, reposTruncated: where.truncated, repoLimit: where.limit, 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 ──────────────────────────────────────────────────────────
|
|
|
|
/** 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; timeoutMs?: number } = {}
|
|
): 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;
|
|
let cutShort = false;
|
|
try {
|
|
out = await git(repoRoot, args, { timeoutMs: opts.timeoutMs });
|
|
} 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.
|
|
if (file.kind === 'untracked' && e.code === 1 && typeof e.stdout === 'string') out = e.stdout;
|
|
// 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: cutShort, binary };
|
|
const cut = out.lastIndexOf('\n', MAX_DIFF_BYTES);
|
|
return { diff: out.slice(0, cut > 0 ? cut : MAX_DIFF_BYTES), truncated: true, binary };
|
|
}
|