Files
Codeman/src/web/repo-status.ts
T
Codeman maintainer bb4ba79791 fix(repo-status): async git, single-flight TTL cache, credential redaction, local-upstream parse
Post-merge follow-ups for #328 (GET /api/system/repo-status):

- Event-loop blocking: every git invocation in repo-status.ts is now async
  (promisified execFile), never execFileSync — the per-remote ls-remote +
  fetch could hold the event loop (SSE, PTY streaming) for up to ~60s per
  request. The whole computation is single-flight with a 45s TTL cache
  (createSingleFlightCache): concurrent requests share one in-flight
  promise, a fresh result is served without spawning git, and a rejected
  compute is never cached. Route handler shape and response fields
  unchanged; remotes still processed sequentially (concurrent fetches in
  one repo contend on ref locks).

- Credential disclosure: the redaction from git-clone.ts is extracted as
  exported redactGitCredentials() (sanitizeGitOutput now uses it) and
  applied via redactRemoteStatus() to every remote card's url and error
  string, so a scheme://user:token@host remote URL (or git stderr echoing
  it) never reaches a client.

- Non-interactive env: runGit() now uses the shared gitNonInteractiveEnv()
  instead of a partial GIT_TERMINAL_PROMPT/BatchMode env, also closing the
  GIT_ASKPASS/SSH_ASKPASS/SSH_ASKPASS_REQUIRE/DISPLAY/GCM_INTERACTIVE
  prompt paths.

- Upstream parse bug: a local-branch upstream (@{upstream} with no slash,
  e.g. after `git branch -u otherbranch`) made slice(0, indexOf('/')) into
  slice(0, -1) and yielded garbage like "maste". parseTrackingRemote()
  (pure, unit-tested) returns null for it, and the bare ref is dropped so
  it cannot be mistaken for a remote-tracking ref downstream.

Tests extended in test/repo-status.test.ts (parseTrackingRemote,
redactGitCredentials/redactRemoteStatus, createSingleFlightCache
single-flight/TTL/rejection semantics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 02:25:43 +02:00

398 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Repository-status check (App Settings → Updates → "Repository
* status").
*
* INFORMATIONAL companion to the release-tag self-updater (`self-update.ts`).
* Where the updater answers "is there a newer published release tag, and do you
* want to `git checkout` it", this module answers "where does my local checkout
* sit relative to the upstream project AND my own fork" — by commit ahead/behind
* count plus a short list of the incoming commits.
*
* This needs the actual commits locally, so it does a READ-ONLY `git fetch` of
* the compared ref per remote (updates only remote-tracking refs under `.git`,
* never the working tree), then `git rev-list`/`git log`. Works uniformly for
* GitHub and non-GitHub remotes (e.g. Bitbucket) — no release tags required.
*
* Remote selection (generalizable): by default the union of `origin` and the
* current branch's `@{upstream}` tracking remote, deduped. Override with the
* `CODEMAN_UPDATE_REMOTES` env var (comma-separated remote names) for any other
* layout (e.g. the common `origin`=fork / `upstream`=canonical convention).
*
* Split PURE helpers (parsing + remote-set/role/compare-ref decisions, unit
* tested) from the IO wrapper `getRepositoryStatus()` (touches git).
*
* PERFORMANCE: every git invocation is ASYNC (`execFile`), never `execFileSync`
* — the fetch path can spend `2 × FETCH_TIMEOUT_MS` per remote on the network,
* and a synchronous version froze the whole event loop (SSE, PTY streaming) for
* up to a minute per request. The computation is additionally single-flight
* with a short TTL cache: concurrent requests share one in-flight promise, and
* a fresh-enough result is served without spawning git at all.
*
* SECURITY: remote URLs (and git stderr echoing them) can embed
* `scheme://user:token@host` credentials, so every `url`/`error` field is
* passed through `redactGitCredentials()` (shared with `git-clone.ts`) before
* it reaches a client.
*
* @module web/repo-status
*/
import { execFile } from 'node:child_process';
import { createRequire } from 'node:module';
import { promisify } from 'node:util';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { gitNonInteractiveEnv, redactGitCredentials } from '../git-clone.js';
import { getInstallInfo } from './self-update.js';
import type { RepoIncomingCommit, RepoRemoteRole, RepoRemoteStatus, RepositoryStatusResult } from '../types/update.js';
const require = createRequire(import.meta.url);
const { version: APP_VERSION } = require('../../package.json') as { version: string };
const execFileAsync = promisify(execFile);
/** Network/git timeout for the fetch path (longer than EXEC_TIMEOUT_MS — hits network). */
const FETCH_TIMEOUT_MS = 15_000;
/** Max incoming commit subjects to list per remote. */
const MAX_INCOMING = 10;
/** Fresh-enough window for a cached status — repeated UI polls reuse it instead of re-running git. */
const STATUS_CACHE_TTL_MS = 45_000;
// ─────────────────────────────────────────────────────────────────────────────
// PURE helpers (unit tested, no IO)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Parse `git rev-list --left-right --count HEAD...<ref>` output.
* Git prints two tab-separated counts: LEFT (commits in HEAD not in ref → ahead)
* and RIGHT (commits in ref not in HEAD → behind). Returns null on malformed input.
*/
export function parseAheadBehind(out: string): { ahead: number; behind: number } | null {
const m = out.trim().match(/^(\d+)\s+(\d+)$/);
if (!m) return null;
return { ahead: parseInt(m[1], 10), behind: parseInt(m[2], 10) };
}
/**
* Parse `git log --oneline` output into commits. Each non-empty line is
* `<sha> <subject>`; the first whitespace-delimited token is the SHA.
*/
export function parseLogLines(out: string): RepoIncomingCommit[] {
const commits: RepoIncomingCommit[] = [];
for (const line of out.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const idx = trimmed.indexOf(' ');
if (idx === -1) {
commits.push({ sha: trimmed, subject: '' });
} else {
commits.push({ sha: trimmed.slice(0, idx), subject: trimmed.slice(idx + 1).trim() });
}
}
return commits;
}
/**
* Extract the default branch name from `git ls-remote --symref <remote> HEAD`
* output (a line like `ref: refs/heads/master\tHEAD`). Returns null if absent.
*
* SECURITY: this parses UNTRUSTED remote output, and the result flows into a
* later `git fetch <remote> <branch>` positional. The capture is constrained to
* start with an alphanumeric (no leading `-`) so a hostile remote can't return
* `ref: refs/heads/--upload-pack=<cmd>\tHEAD` and smuggle an argv flag (RCE) —
* see `isSafeGitPositional` for the defense-in-depth re-check at the call site.
*/
export function parseSymrefDefaultBranch(out: string): string | null {
const m = out.match(/^ref:\s+refs\/heads\/([A-Za-z0-9_./][A-Za-z0-9_./+-]*)\s+HEAD$/m);
return m ? m[1] : null;
}
/**
* Reject a value that would be unsafe as a git positional argument (remote name
* or branch). A leading `-` lets untrusted ls-remote/symref output or a stray
* `CODEMAN_UPDATE_REMOTES` entry inject an option (e.g. `--upload-pack=<cmd>`)
* into a subsequent `git fetch`. Empty values are rejected too.
*/
export function isSafeGitPositional(value: string | null | undefined): value is string {
return typeof value === 'string' && value.length > 0 && !value.startsWith('-');
}
/** Split a comma-separated env value into trimmed, non-empty names. */
export function parseRemotesEnv(value: string | null | undefined): string[] {
if (!value) return [];
return value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
/**
* Decide which remote NAMES the status view covers, in display order.
*
* - If `envRemotes` is non-empty, use exactly those that actually exist (order
* preserved). This is the full-control escape hatch.
* - Otherwise the union of `origin` (if it exists) and the tracking remote (if
* any), deduped. The tracking remote is listed first when it isn't `origin`,
* so "your fork" leads and "upstream" follows.
*/
export function resolveRemoteSet(opts: {
existingRemotes: string[];
trackingRemote: string | null;
envRemotes: string[];
}): string[] {
const exists = new Set(opts.existingRemotes);
if (opts.envRemotes.length > 0) {
return dedupe(opts.envRemotes.filter((n) => exists.has(n)));
}
const out: string[] = [];
if (opts.trackingRemote && exists.has(opts.trackingRemote) && opts.trackingRemote !== 'origin') {
out.push(opts.trackingRemote);
}
if (exists.has('origin')) out.push('origin');
if (opts.trackingRemote && exists.has(opts.trackingRemote)) out.push(opts.trackingRemote);
return dedupe(out);
}
/** Classify a remote's role relative to the tracking remote. */
export function roleForRemote(name: string, trackingRemote: string | null): RepoRemoteRole {
if (trackingRemote && name === trackingRemote) return 'tracking';
if (name === 'origin' || name === 'upstream') return 'upstream';
return 'other';
}
function dedupe(names: string[]): string[] {
return [...new Set(names)];
}
/**
* Extract the remote name from an `@{upstream}` short ref like `origin/master`.
*
* A ref with NO slash is a LOCAL-branch upstream (`git branch -u otherbranch`):
* `rev-parse --abbrev-ref @{upstream}` prints just the branch name, and there
* is no remote in it — the old `slice(0, indexOf('/'))` became `slice(0, -1)`
* there and yielded garbage like `maste`. Returns null for that case (treated
* as "no tracking remote"), and for empty/degenerate refs.
*/
export function parseTrackingRemote(trackingRef: string | null | undefined): string | null {
if (!trackingRef) return null;
const idx = trackingRef.indexOf('/');
if (idx <= 0) return null;
return trackingRef.slice(0, idx);
}
/**
* Redact embedded `scheme://user:secret@host` credentials from the fields of
* one remote's status that can carry them: the remote URL itself, and the
* error string (which may include git stderr echoing that URL back).
*/
export function redactRemoteStatus(status: RepoRemoteStatus): RepoRemoteStatus {
const out: RepoRemoteStatus = { ...status, url: redactGitCredentials(status.url) };
if (out.error !== undefined) out.error = redactGitCredentials(out.error);
return out;
}
/**
* Single-flight TTL cache around an async compute: concurrent callers await
* the SAME in-flight promise, and a result younger than `ttlMs` is returned
* without recomputing. A rejected compute is never cached, so the next call
* retries. `now` is injectable for deterministic TTL tests.
*/
export function createSingleFlightCache<T>(
ttlMs: number,
compute: () => Promise<T>
): { get(now?: number): Promise<T> } {
let cachedAt = 0;
let cachedValue: T | undefined;
let hasValue = false;
let inFlight: Promise<T> | null = null;
return {
get(now = Date.now()): Promise<T> {
if (hasValue && now - cachedAt < ttlMs) return Promise.resolve(cachedValue as T);
if (inFlight) return inFlight;
inFlight = compute().then(
(value) => {
cachedValue = value;
hasValue = true;
cachedAt = Date.now();
inFlight = null;
return value;
},
(err: unknown) => {
inFlight = null;
throw err;
}
);
return inFlight;
},
};
}
// ─────────────────────────────────────────────────────────────────────────────
// IO wrapper
// ─────────────────────────────────────────────────────────────────────────────
interface GitResult {
ok: boolean;
stdout: string;
stderr: string;
}
/**
* Run git non-interactively (no credential or SSH prompts — a missing key/cred
* fails fast instead of hanging; env via the shared `gitNonInteractiveEnv()`,
* which also closes the askpass/GCM/DISPLAY prompt paths). ASYNC on purpose:
* the fetch path hits the network for up to `FETCH_TIMEOUT_MS`, and a sync
* spawn would block the event loop for the whole wait.
*/
async function runGit(args: string[], cwd: string, timeout = EXEC_TIMEOUT_MS): Promise<GitResult> {
try {
const { stdout } = await execFileAsync('git', args, {
cwd,
encoding: 'utf-8',
timeout,
env: gitNonInteractiveEnv(),
});
return { ok: true, stdout: stdout.trim(), stderr: '' };
} catch (err: unknown) {
const e = err as { stdout?: Buffer | string; stderr?: Buffer | string };
return {
ok: false,
stdout: e.stdout ? String(e.stdout).trim() : '',
stderr: e.stderr ? String(e.stderr).trim() : '',
};
}
}
/** First line of stderr, trimmed — a compact human-readable failure reason. */
function firstLine(s: string): string {
return (
s
.split('\n')
.find((l) => l.trim())
?.trim() ?? 'git command failed'
);
}
/** Compute ahead/behind + incoming commits for one already-selected remote. */
async function statusForRemote(
dir: string,
name: string,
trackingRemote: string,
trackingRef: string | null
): Promise<RepoRemoteStatus> {
const role = roleForRemote(name, trackingRemote);
const url = (await runGit(['remote', 'get-url', name], dir)).stdout || '';
const base: RepoRemoteStatus = { name, url, role, compareRef: '', ahead: 0, behind: 0, incoming: [] };
// SECURITY: the remote name reaches git as a positional; reject a `-` prefix
// (e.g. a stray CODEMAN_UPDATE_REMOTES entry) before it can act as a flag.
if (!isSafeGitPositional(name)) {
return { ...base, error: `Refusing unsafe remote name "${name}".` };
}
// Resolve the compare ref + the remote branch to fetch.
let branch: string | null;
if (role === 'tracking' && trackingRef) {
// e.g. trackingRef = "bitbucket/local" → branch = "local"
branch = trackingRef.slice(name.length + 1) || null;
} else {
const symref = await runGit(['ls-remote', '--symref', name, 'HEAD'], dir, FETCH_TIMEOUT_MS);
branch = symref.ok ? parseSymrefDefaultBranch(symref.stdout) : null;
if (!branch && !symref.ok) {
return { ...base, error: `Could not reach ${name}: ${firstLine(symref.stderr)}` };
}
branch = branch ?? 'master';
}
if (!branch) return { ...base, error: `Could not resolve a branch on ${name}.` };
// SECURITY: defense-in-depth — `branch` may come from untrusted symref output
// or a tracking-ref slice; never let a `-`-prefixed value reach `git fetch`.
if (!isSafeGitPositional(branch)) {
return { ...base, error: `Refusing unsafe branch name "${branch}" from ${name}.` };
}
const compareRef = `${name}/${branch}`;
// Read-only fetch of just that ref so the local rev-list/log can see it.
// `--` ends option parsing so neither `name` nor `branch` can be read as a flag.
const fetched = await runGit(['fetch', '--no-tags', name, '--', branch], dir, FETCH_TIMEOUT_MS);
if (!fetched.ok) {
return { ...base, compareRef, error: `Could not fetch ${compareRef}: ${firstLine(fetched.stderr)}` };
}
const counts = await runGit(['rev-list', '--left-right', '--count', `HEAD...${compareRef}`], dir);
if (!counts.ok) {
return { ...base, compareRef, error: `Could not compare against ${compareRef}: ${firstLine(counts.stderr)}` };
}
const ab = parseAheadBehind(counts.stdout);
if (!ab) return { ...base, compareRef, error: `Unexpected git output comparing ${compareRef}.` };
const log = await runGit(['log', '--oneline', '-n', String(MAX_INCOMING), `HEAD..${compareRef}`], dir);
const incoming = log.ok ? parseLogLines(log.stdout) : [];
return { ...base, compareRef, ahead: ab.ahead, behind: ab.behind, incoming };
}
/** The uncached computation behind `getRepositoryStatus()`. */
async function computeRepositoryStatus(): Promise<RepositoryStatusResult> {
const checkedAt = Date.now();
const info = getInstallInfo();
const base: RepositoryStatusResult = {
checkedAt,
isGit: info.installKind === 'git',
currentVersion: info.currentVersion || APP_VERSION,
remotes: [],
};
if (info.installKind !== 'git') {
return { ...base, error: 'Not a git install — repository status is unavailable.' };
}
const dir = info.installDir;
// Tracking ref of the current branch, e.g. "bitbucket/local" (empty if none).
const trackingRef =
(await runGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], dir)).stdout || null;
const trackingRemote = parseTrackingRemote(trackingRef);
// A local-branch upstream (no slash) carries no remote — drop the ref too, so
// nothing downstream can mistake a bare branch name for a remote-tracking ref.
const remoteTrackingRef = trackingRemote ? trackingRef : null;
const remotesOut = await runGit(['remote'], dir);
const existingRemotes = remotesOut.ok
? remotesOut.stdout
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
: [];
const selected = resolveRemoteSet({
existingRemotes,
trackingRemote,
envRemotes: parseRemotesEnv(process.env.CODEMAN_UPDATE_REMOTES),
});
if (selected.length === 0) {
return { ...base, error: 'No comparable remotes found (set CODEMAN_UPDATE_REMOTES to choose).' };
}
// Sequential on purpose: concurrent `git fetch` in one repo can contend on
// ref locks, and the event loop no longer cares how long this takes.
const remotes: RepoRemoteStatus[] = [];
for (const name of selected) {
// SECURITY: redact `scheme://user:token@host` credentials from the URL and
// any error string (git stderr echoes the URL back) before they leave the server.
remotes.push(redactRemoteStatus(await statusForRemote(dir, name, trackingRemote ?? '', remoteTrackingRef)));
}
return { ...base, remotes };
}
const statusCache = createSingleFlightCache(STATUS_CACHE_TTL_MS, computeRepositoryStatus);
/**
* Inspect how the local checkout sits relative to the configured remotes.
* Each remote is fetched + compared independently; a single unreachable remote
* surfaces as that card's `error` and never fails the whole call.
*
* Single-flight + TTL-cached: concurrent requests share one in-flight
* computation, and a result younger than `STATUS_CACHE_TTL_MS` is served
* without spawning git.
*/
export function getRepositoryStatus(): Promise<RepositoryStatusResult> {
return statusCache.get();
}