Files
web-terminal/src/http/git-log.ts
Yaojia Wang 8fe1f52e5d feat(projects): show real git state on the project detail page
The page carried one bare `●` for "dirty" and nothing else, so "do I have
commits I haven't pushed" and "which worktree am I in" still meant dropping
into a terminal. Design mock: docs/mockups/project-detail-git.html; plan and
task breakdown (G1-G7): docs/plans/w6-project-git-panel.md.

One rule drives the whole feature: ahead/behind compare against `@{u}`, a
LOCALLY CACHED remote ref that only a fetch moves. This repo was the live
example while building — `↑9` true, `↓0` false, because FETCH_HEAD had not
moved in 19 days. So:

  - `ahead` needs only local refs and is never flagged.
  - `behind` is flagged `stale` once FETCH_HEAD is older than an hour.
  - Exactly ONE state may render green: ↑0 ↓0 AND a fresh fetch. Green means
    "I checked, ignore this"; getting it wrong is lying to the user.
  - No upstream (the normal state of a fresh worktree branch) leaves ahead and
    behind undefined — it renders an explicit `no upstream`, never the green
    path. That fall-through is the easiest bug to ship here.

What landed:

G1  SyncState (upstream/ahead/behind/lastFetchMs/detached) + ProjectDetail.sync
    and .dirtyCount. All additive and optional — the Android and iOS clients
    decode these shapes. The ahead/behind helper already existed for the list
    view; buildProjectDetail had simply never called it.
    Fixes a pre-existing bug on the way: readBranch read <repo>/.git/HEAD
    directly, so it returned nothing inside a LINKED worktree, where .git is a
    file. resolveGitDirs now resolves both the per-worktree gitdir (HEAD) and
    the shared common dir (FETCH_HEAD).

G2  POST /projects/git/fetch. Same discipline as push: the remote is derived
    server-side and no remote or refspec is ever read from the body, so a
    client cannot aim it at an arbitrary URL. Touches refs/remotes only — no
    working tree, no index, no merge; it is not a pull. Own rate-limit bucket
    so refreshes cannot eat the budget a real push needs. On failure
    lastFetchMs is left alone, so the UI keeps saying "stale" instead of
    pretending it refreshed.

G3  makeSyncBand replaces the bare dot: upstream name, ↑n, ↓n, stale flag,
    dirty count, Fetch button (disabled on a detached HEAD).

G4  The commit list marks unpushed commits and draws the upstream boundary
    once, after the last of them. Marking is server-side from `rev-list`,
    deliberately NOT "the first N rows": `git log` is date-ordered, so merging
    an older branch interleaves unpushed commits BELOW pushed ones, and that
    shortcut fails in the dangerous direction — calling an unpushed commit
    pushed. A regression test builds exactly that backdated-merge shape.

G5  The worktree section is always "Worktrees (n)" (it used to rename itself
    to "Branch" at n=1) and the current row carries its own state chips.

G6  Cost control. The plan called for a .git-mtime cache; that was dropped
    during implementation because a fingerprint over HEAD/index/reflog does
    NOT move when push updates a remote-tracking ref — the cached `ahead`
    would still claim "9 to push" right after a successful push, which is the
    exact lie the feature exists to prevent. Replaced with three measures that
    cannot go stale: in-flight coalescing (N devices watching one repo cost
    one probe, entry dropped as it settles, nothing cached across time),
    skipping the re-render when the payload is byte-identical (this also stops
    the 5 s re-mount of the commit log, two more git spawns per tick), and
    pausing the timer while the document is hidden.

G7  Per-worktree state via GET /projects/worktree/state, kept narrower than
    /projects/detail so N rows do not pay for worktree listing and CLAUDE.md
    reads nothing renders. Needed an unplanned prerequisite: ProjectSessionRef
    carried no cwd, so sessions could not be attributed to a worktree. Added
    it, plus countSessionsByWorktree, which matches DEEPEST-first because
    .claude/worktrees/<name> lives INSIDE the main checkout and prefix
    matching would count every worktree session against the parent repo too.

Out of scope, unchanged: no reset, no checkout, no clean, no rebase, no
force-push. stage/commit/push stay exactly as they were.

Verified: tsc and build clean; 46 new tests.
2026-07-29 17:12:00 +02:00

169 lines
6.8 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.

/**
* 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 },
)
const parsed = parseGitLog(stdout, n)
return await markUnpushed(repoPath, parsed, opts.timeoutMs)
} catch {
return { commits: [], truncated: false }
}
}
/** Cap on how many unpushed commits we enumerate — a runaway branch must not
* turn one log read into an unbounded buffer. */
const UNPUSHED_MAX = 200
/**
* w6/G4 — tag the commits that exist only locally, and name the upstream so the
* client can draw the boundary between pushed and unpushed history.
*
* Marking is done HERE, from the authoritative `rev-list` set, and never inferred
* on the client from row order: `git log` is date-ordered, so merging an older
* branch interleaves unpushed commits below pushed ones and "the first N rows are
* unpushed" silently mislabels them as pushed — the same class of confident lie
* the sync band exists to prevent.
*
* No upstream / detached / empty repo → the input is returned untouched (no
* `upstream`, no marks), so the client draws no boundary. Never throws.
*/
async function markUnpushed(
repoPath: string,
result: GitLogResult,
timeoutMs: number,
): Promise<GitLogResult> {
if (result.commits.length === 0) return result
let upstream: string
try {
const { stdout } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
{ cwd: repoPath, timeout: timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER },
)
upstream = stdout.trim()
if (upstream === '') return result
} catch {
return result // no upstream configured / detached — nothing to compare against
}
let fullHashes: string[]
try {
const { stdout } = await execFileAsync(
'git',
['rev-list', `--max-count=${UNPUSHED_MAX}`, '@{u}..HEAD'],
{ cwd: repoPath, timeout: timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER },
)
fullHashes = stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '')
} catch {
return result
}
// `hash` is abbreviated (%h) while rev-list yields full SHAs, and the two can
// disagree on width, so match by prefix rather than equality. Bounded by
// GIT_LOG_MAX × UNPUSHED_MAX string compares — trivial, and always correct.
const commits = result.commits.map((c) =>
fullHashes.some((full) => full.startsWith(c.hash)) ? { ...c, unpushed: true } : c,
)
return { ...result, commits, upstream }
}