feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)
Four small, high-delight features that turn passive capture into glanceable signals.
- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
into the existing concurrent per-repo metadata pass (git rev-list --count
--left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
(Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
/config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.
All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
This commit is contained in:
109
src/http/git-log.ts
Normal file
109
src/http/git-log.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* src/http/git-log.ts (W3 quick-wins d) — read-only recent-commit log.
|
||||
*
|
||||
* The server stays a byte-shuttle: this is an out-of-band side-channel that runs
|
||||
* `git log` in a directory and PARSES its output into CommitLogEntry[]. Parsing
|
||||
* lives ONLY here; public/git-log.ts is render-only (mirrors diff.ts / gh.ts).
|
||||
*
|
||||
* Delimiter design (robust against nasty subjects): the format is
|
||||
* %h %x1f %ct %x1f %s with -z (records separated by NUL)
|
||||
* so a US (0x1f) field separator + a NUL (0x00) record separator can never be
|
||||
* corrupted by a subject containing tabs, spaces or newlines. We never parse the
|
||||
* fragile `--oneline` shape.
|
||||
*
|
||||
* Security (mirrors diff.ts):
|
||||
* - execFile('git', [...]) with NO shell; timeout + maxBuffer bound DoS.
|
||||
* - `repoPath` is the cwd, never interpolated into argv; `n` is coerced to an
|
||||
* int and clamped BEFORE reaching argv. Path→repo validation is the route's
|
||||
* job (isValidGitDir, SEC-H7), exactly like /projects/diff.
|
||||
* - parseGitLog NEVER throws: malformed records are skipped; getGitLog is
|
||||
* best-effort and returns an empty result rather than rejecting.
|
||||
* - subjects are carried verbatim and rendered as inert text (textContent) in
|
||||
* the FE — never HTML.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import type { CommitLogEntry, GitLogResult } from '../types.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
/** Hard cap on the number of commits a single request may return (DoS bound). */
|
||||
export const GIT_LOG_MAX = 50
|
||||
/** Default number of commits when the caller does not specify `n`. */
|
||||
export const GIT_LOG_DEFAULT = 20
|
||||
/** Cap a single commit subject so a pathological message can't bloat the payload. */
|
||||
const SUBJECT_MAX_LEN = 500
|
||||
/** Bound the captured stdout (DoS guard); 1 MB is ample for ≤50 subjects. */
|
||||
const GIT_LOG_MAX_BUFFER = 1024 * 1024
|
||||
/** Field separator (US, 0x1f) and record separator (NUL, 0x00). */
|
||||
const FIELD_SEP = '\x1f'
|
||||
const RECORD_SEP = '\x00'
|
||||
|
||||
/**
|
||||
* Clamp a possibly-junk `n` to an integer in [1, GIT_LOG_MAX]. Non-numeric /
|
||||
* missing → GIT_LOG_DEFAULT. Never throws.
|
||||
*/
|
||||
export function clampLogCount(n: unknown): number {
|
||||
const parsed = typeof n === 'number' ? n : Number.parseInt(String(n ?? ''), 10)
|
||||
if (!Number.isFinite(parsed)) return GIT_LOG_DEFAULT
|
||||
const floored = Math.floor(parsed)
|
||||
if (floored < 1) return 1
|
||||
if (floored > GIT_LOG_MAX) return GIT_LOG_MAX
|
||||
return floored
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git log -z --format=%h%x1f%ct%x1f%s` output into CommitLogEntry[].
|
||||
* Records are NUL-separated; fields are US-separated. A record missing a field
|
||||
* or with a non-numeric timestamp is skipped (never throws). `max` caps the
|
||||
* returned entries and drives the `truncated` flag (records === max ⇒ there may
|
||||
* be more). Empty stdout → an empty, non-truncated result.
|
||||
*/
|
||||
export function parseGitLog(stdout: string, max: number): GitLogResult {
|
||||
if (typeof stdout !== 'string' || stdout.length === 0) {
|
||||
return { commits: [], truncated: false }
|
||||
}
|
||||
const records = stdout.split(RECORD_SEP).filter((r) => r.length > 0)
|
||||
const commits: CommitLogEntry[] = []
|
||||
for (const record of records) {
|
||||
const parts = record.split(FIELD_SEP)
|
||||
if (parts.length < 3) continue // malformed — missing a field
|
||||
const hash = (parts[0] ?? '').trim()
|
||||
const secs = Number.parseInt(parts[1] ?? '', 10)
|
||||
// Subject may (in theory) contain a US char; re-join the tail so it is intact.
|
||||
const subject = parts.slice(2).join(FIELD_SEP)
|
||||
if (hash === '' || !Number.isFinite(secs) || secs < 0) continue
|
||||
commits.push({
|
||||
hash,
|
||||
at: secs * 1000,
|
||||
subject: subject.length > SUBJECT_MAX_LEN ? subject.slice(0, SUBJECT_MAX_LEN) : subject,
|
||||
})
|
||||
}
|
||||
const truncated = commits.length >= max && max > 0
|
||||
return { commits: max > 0 ? commits.slice(0, max) : commits, truncated }
|
||||
}
|
||||
|
||||
export interface GetGitLogOptions {
|
||||
n?: number
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a repo's recent commits (newest first) as a structured GitLogResult.
|
||||
* `repoPath` must already be a validated absolute git directory (route layer,
|
||||
* SEC-H7). Best-effort: any git failure yields an empty result, never throws.
|
||||
*/
|
||||
export async function getGitLog(repoPath: string, opts: GetGitLogOptions): Promise<GitLogResult> {
|
||||
const n = clampLogCount(opts.n)
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['log', '--no-color', '-z', '-n', String(n), '--format=%h%x1f%ct%x1f%s'],
|
||||
{ cwd: repoPath, timeout: opts.timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER },
|
||||
)
|
||||
return parseGitLog(stdout, n)
|
||||
} catch {
|
||||
return { commits: [], truncated: false }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user