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.
169 lines
6.8 KiB
TypeScript
169 lines
6.8 KiB
TypeScript
/**
|
||
* 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 }
|
||
}
|