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.
168 lines
7.0 KiB
TypeScript
168 lines
7.0 KiB
TypeScript
/**
|
|
* public/git-log.ts (W3 quick-wins d) — render-only recent-commit list.
|
|
*
|
|
* Fetches GET /projects/log for a repo and renders each commit as an inert row.
|
|
* Zero parsing lives here (parsing is in src/http/git-log.ts). It NEVER throws
|
|
* and degrades to a short inert message on any failure.
|
|
*
|
|
* Security: SEC-H5 — ALL text is set via textContent / el(). Zero innerHTML. A
|
|
* commit subject is attacker-influenced (anyone who can push to a repo the host
|
|
* can read), so it appears strictly as literal text.
|
|
*/
|
|
|
|
import type { CommitLogEntry, GitLogResult } from '../src/types.js'
|
|
|
|
/* ── DOM helper ──────────────────────────────────────────────────────────────── */
|
|
|
|
/** Create an element with an optional CSS class and text content. */
|
|
function el<K extends keyof HTMLElementTagNameMap>(
|
|
tag: K,
|
|
cls?: string,
|
|
text?: string,
|
|
): HTMLElementTagNameMap[K] {
|
|
const node = document.createElement(tag)
|
|
if (cls) node.className = cls
|
|
if (text !== undefined) node.textContent = text
|
|
return node
|
|
}
|
|
|
|
/* ── normalize (never trust the API shape) ───────────────────────────────────── */
|
|
|
|
/** Coerce one untrusted /projects/log element into a safe CommitLogEntry, or null. */
|
|
function normalizeCommit(raw: unknown): CommitLogEntry | null {
|
|
if (raw === null || typeof raw !== 'object') return null
|
|
const o = raw as Record<string, unknown>
|
|
if (typeof o['hash'] !== 'string' || typeof o['subject'] !== 'string') return null
|
|
if (typeof o['at'] !== 'number' || !Number.isFinite(o['at'])) return null
|
|
const entry: CommitLogEntry = { hash: o['hash'], at: o['at'], subject: o['subject'] }
|
|
// w6/G4: only an explicit `true` marks a commit unpushed — anything else (absent,
|
|
// truthy junk, an older server) must fall back to "pushed", because the failure
|
|
// that matters is calling an unpushed commit pushed, not the reverse.
|
|
return o['unpushed'] === true ? { ...entry, unpushed: true } : entry
|
|
}
|
|
|
|
/** Coerce an untrusted GET /projects/log response into a GitLogResult, or null. */
|
|
export function normalizeGitLog(raw: unknown): GitLogResult | null {
|
|
if (raw === null || typeof raw !== 'object') return null
|
|
const o = raw as Record<string, unknown>
|
|
if (!Array.isArray(o['commits'])) return null
|
|
const commits = o['commits']
|
|
.map(normalizeCommit)
|
|
.filter((c): c is CommitLogEntry => c !== null)
|
|
const upstream = typeof o['upstream'] === 'string' && o['upstream'] !== '' ? o['upstream'] : undefined
|
|
return {
|
|
commits,
|
|
truncated: o['truncated'] === true,
|
|
...(upstream !== undefined ? { upstream } : {}),
|
|
}
|
|
}
|
|
|
|
/* ── fetch ───────────────────────────────────────────────────────────────────── */
|
|
|
|
/** Fetch the recent-commit log for a repo path. null on any error (best-effort). */
|
|
export async function fetchGitLog(repoPath: string): Promise<GitLogResult | null> {
|
|
try {
|
|
if (typeof fetch === 'undefined') return null
|
|
const res = await fetch(`/projects/log?path=${encodeURIComponent(repoPath)}`)
|
|
if (!res.ok) return null
|
|
return normalizeGitLog(await res.json())
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/* ── render ──────────────────────────────────────────────────────────────────── */
|
|
|
|
/** Coarse "Ns / Nm / Nh / Nd ago" formatter (local copy — avoids importing xterm). */
|
|
function relTime(ms: number): string {
|
|
const s = Math.max(0, (Date.now() - ms) / 1000)
|
|
if (s < 60) return `${Math.floor(s)}s`
|
|
if (s < 3600) return `${Math.floor(s / 60)}m`
|
|
if (s < 86400) return `${Math.floor(s / 3600)}h`
|
|
return `${Math.floor(s / 86400)}d`
|
|
}
|
|
|
|
/** One commit row: short hash · relative time · subject (all inert text).
|
|
* w6/G4: an unpushed commit gets a rail + ↑ so the eye reads the group, not the
|
|
* individual rows. */
|
|
function renderCommitRow(c: CommitLogEntry): HTMLElement {
|
|
const row = el('div', 'proj-commit-row')
|
|
if (c.unpushed === true) {
|
|
row.classList.add('proj-commit-unpushed')
|
|
const mark = el('span', 'proj-commit-mark', '↑')
|
|
mark.title = 'Not pushed yet'
|
|
row.append(mark)
|
|
}
|
|
row.append(el('span', 'proj-commit-hash', c.hash))
|
|
row.append(el('span', 'proj-commit-time', `${relTime(c.at)} ago`))
|
|
row.append(el('span', 'proj-commit-subject', c.subject)) // attacker-influenced → textContent
|
|
return row
|
|
}
|
|
|
|
/** Render a GitLogResult into a container (clears first). Empty → an inert note. */
|
|
export function renderGitLog(container: HTMLElement, log: GitLogResult): void {
|
|
container.textContent = ''
|
|
if (log.commits.length === 0) {
|
|
container.append(el('div', 'proj-empty', 'No commits yet.'))
|
|
return
|
|
}
|
|
const list = el('div', 'proj-commitlog-list')
|
|
// w6/G4: draw the upstream's position ONCE, right after the last unpushed row.
|
|
// Only with a named upstream — with nothing to compare against there is no
|
|
// boundary to claim, and an invented one would be a lie about what is pushed.
|
|
const lastUnpushed = log.upstream === undefined
|
|
? -1
|
|
: log.commits.reduce((acc, c, i) => (c.unpushed === true ? i : acc), -1)
|
|
|
|
log.commits.forEach((c, i) => {
|
|
list.append(renderCommitRow(c))
|
|
if (i === lastUnpushed) {
|
|
const boundary = el('div', 'proj-commit-boundary')
|
|
boundary.append(el('span', 'proj-commit-boundary-label', log.upstream as string))
|
|
boundary.append(el('span', 'proj-commit-boundary-rule'))
|
|
list.append(boundary)
|
|
}
|
|
})
|
|
container.append(list)
|
|
if (log.truncated) {
|
|
container.append(el('div', 'proj-commit-more', `Showing the latest ${log.commits.length} commits.`))
|
|
}
|
|
}
|
|
|
|
/* ── mount ───────────────────────────────────────────────────────────────────── */
|
|
|
|
/** Handle returned by mountGitLog for cleanup. */
|
|
export interface GitLogHandle {
|
|
destroy(): void
|
|
}
|
|
|
|
/**
|
|
* Mount a recent-commit list into `container`: show a loading placeholder, fetch
|
|
* the log, then swap in the rows. A fetch failure degrades to a short inert
|
|
* message (never throws). destroy() removes the node and cancels the swap.
|
|
*/
|
|
export function mountGitLog(container: HTMLElement, repoPath: string): GitLogHandle {
|
|
let destroyed = false
|
|
|
|
container.textContent = ''
|
|
container.append(el('div', 'proj-commitlog-loading', 'Loading commits…'))
|
|
|
|
void (async () => {
|
|
const log = await fetchGitLog(repoPath)
|
|
if (destroyed) return
|
|
if (log === null) {
|
|
container.textContent = ''
|
|
container.append(el('div', 'proj-empty', 'Could not read recent commits.'))
|
|
return
|
|
}
|
|
renderGitLog(container, log)
|
|
})()
|
|
|
|
return {
|
|
destroy() {
|
|
destroyed = true
|
|
container.textContent = ''
|
|
},
|
|
}
|
|
}
|