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.
214 lines
7.9 KiB
TypeScript
214 lines
7.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* test/git-log.test.ts (W3 quick-wins d) — recent-commit list (public/git-log.ts).
|
|
*
|
|
* Pure normalize/render + the mountGitLog wiring with a mocked fetch. Security:
|
|
* commit subjects are attacker-influenced, so a subject containing an <img
|
|
* onerror> payload must appear verbatim as text (no HTML injection). A fetch
|
|
* failure degrades to an inert message (best-effort, never throws).
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import type { GitLogResult } from '../src/types.js'
|
|
import {
|
|
normalizeGitLog,
|
|
renderGitLog,
|
|
fetchGitLog,
|
|
mountGitLog,
|
|
} from '../public/git-log.js'
|
|
|
|
function makeLog(over: Partial<GitLogResult> = {}): GitLogResult {
|
|
return {
|
|
commits: [
|
|
{ hash: 'abc1234', at: Date.now() - 3600_000, subject: 'first commit' },
|
|
{ hash: 'def5678', at: Date.now() - 7200_000, subject: 'second commit' },
|
|
],
|
|
truncated: false,
|
|
...over,
|
|
}
|
|
}
|
|
|
|
function mockFetch(body: unknown, ok = true): ReturnType<typeof vi.fn> {
|
|
const fn = vi.fn(async () => {
|
|
if (body === null) throw new Error('network down')
|
|
return { ok, json: async () => body } as Response
|
|
})
|
|
vi.stubGlobal('fetch', fn)
|
|
return fn
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
// ── normalizeGitLog ───────────────────────────────────────────────────────────
|
|
|
|
describe('normalizeGitLog', () => {
|
|
it('returns null for a non-object / missing commits array', () => {
|
|
expect(normalizeGitLog(null)).toBeNull()
|
|
expect(normalizeGitLog({ truncated: true })).toBeNull()
|
|
})
|
|
|
|
it('keeps well-formed commits and drops malformed ones', () => {
|
|
const out = normalizeGitLog({
|
|
commits: [
|
|
{ hash: 'h1', at: 1000, subject: 'ok' },
|
|
{ hash: 'h2', at: 'nope', subject: 'bad-at' },
|
|
{ hash: 5, at: 1, subject: 'bad-hash' },
|
|
{ at: 1, subject: 'missing-hash' },
|
|
],
|
|
truncated: true,
|
|
})
|
|
expect(out?.commits.map((c) => c.hash)).toEqual(['h1'])
|
|
expect(out?.truncated).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ── renderGitLog ──────────────────────────────────────────────────────────────
|
|
|
|
describe('renderGitLog', () => {
|
|
it('renders one row per commit with hash / time / subject', () => {
|
|
const host = document.createElement('div')
|
|
renderGitLog(host, makeLog())
|
|
const rows = host.querySelectorAll('.proj-commit-row')
|
|
expect(rows).toHaveLength(2)
|
|
expect(rows[0]?.querySelector('.proj-commit-hash')?.textContent).toBe('abc1234')
|
|
expect(rows[0]?.querySelector('.proj-commit-subject')?.textContent).toBe('first commit')
|
|
})
|
|
|
|
it('renders an empty note when there are no commits', () => {
|
|
const host = document.createElement('div')
|
|
renderGitLog(host, makeLog({ commits: [] }))
|
|
expect(host.querySelector('.proj-empty')).not.toBeNull()
|
|
expect(host.querySelector('.proj-commit-row')).toBeNull()
|
|
})
|
|
|
|
it('shows a truncation note when truncated', () => {
|
|
const host = document.createElement('div')
|
|
renderGitLog(host, makeLog({ truncated: true }))
|
|
expect(host.querySelector('.proj-commit-more')).not.toBeNull()
|
|
})
|
|
|
|
it('renders a subject with an HTML payload as inert text (SEC-H5)', () => {
|
|
const host = document.createElement('div')
|
|
const xss = '<img src=x onerror=alert(1)>'
|
|
renderGitLog(host, makeLog({ commits: [{ hash: 'h1', at: Date.now(), subject: xss }] }))
|
|
const subj = host.querySelector('.proj-commit-subject')
|
|
expect(subj?.textContent).toBe(xss) // verbatim text
|
|
expect(host.querySelectorAll('img').length).toBe(0) // no element injected
|
|
})
|
|
})
|
|
|
|
// ── fetchGitLog / mountGitLog ─────────────────────────────────────────────────
|
|
|
|
describe('fetchGitLog', () => {
|
|
it('requests /projects/log with the encoded repo path', async () => {
|
|
const fetchFn = mockFetch(makeLog())
|
|
await fetchGitLog('/home/u/my repo')
|
|
expect(fetchFn).toHaveBeenCalledWith('/projects/log?path=%2Fhome%2Fu%2Fmy%20repo')
|
|
})
|
|
|
|
it('returns null on a fetch failure (best-effort)', async () => {
|
|
mockFetch(null)
|
|
expect(await fetchGitLog('/x')).toBeNull()
|
|
})
|
|
|
|
it('returns null on a non-ok response', async () => {
|
|
mockFetch({}, false)
|
|
expect(await fetchGitLog('/x')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('mountGitLog', () => {
|
|
it('shows a loading placeholder, then swaps in commit rows', async () => {
|
|
mockFetch(makeLog())
|
|
const host = document.createElement('div')
|
|
mountGitLog(host, '/repo')
|
|
expect(host.querySelector('.proj-commitlog-loading')).not.toBeNull()
|
|
await vi.waitFor(() => {
|
|
expect(host.querySelector('.proj-commit-row')).not.toBeNull()
|
|
})
|
|
})
|
|
|
|
it('degrades to an inert message on a fetch failure', async () => {
|
|
mockFetch(null)
|
|
const host = document.createElement('div')
|
|
mountGitLog(host, '/repo')
|
|
await vi.waitFor(() => {
|
|
expect(host.querySelector('.proj-empty')).not.toBeNull()
|
|
})
|
|
})
|
|
|
|
it('destroy() clears the container and cancels the swap', async () => {
|
|
mockFetch(makeLog())
|
|
const host = document.createElement('div')
|
|
const handle = mountGitLog(host, '/repo')
|
|
handle.destroy()
|
|
// give the async swap a chance — it must not repopulate after destroy
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
expect(host.querySelector('.proj-commit-row')).toBeNull()
|
|
})
|
|
})
|
|
|
|
/* ── w6/G4 unpushed rows + the upstream boundary ───────────────────────────── */
|
|
|
|
describe('renderGitLog — unpushed boundary (w6 G4)', () => {
|
|
function entry(hash: string, subject: string, unpushed?: boolean) {
|
|
return { hash, at: Date.now(), subject, ...(unpushed === true ? { unpushed: true } : {}) }
|
|
}
|
|
|
|
it('marks unpushed rows and draws the boundary exactly once, after the last one', () => {
|
|
const host = document.createElement('div')
|
|
renderGitLog(host, {
|
|
commits: [
|
|
entry('aaa1111', 'newest local', true),
|
|
entry('bbb2222', 'older local', true),
|
|
entry('ccc3333', 'already pushed'),
|
|
entry('ddd4444', 'also pushed'),
|
|
],
|
|
truncated: false,
|
|
upstream: 'origin/develop',
|
|
})
|
|
|
|
expect(host.querySelectorAll('.proj-commit-unpushed').length).toBe(2)
|
|
const boundaries = host.querySelectorAll('.proj-commit-boundary')
|
|
expect(boundaries.length).toBe(1)
|
|
expect(boundaries[0]!.textContent).toContain('origin/develop')
|
|
|
|
const rows = Array.from(host.querySelectorAll('.proj-commit-row, .proj-commit-boundary'))
|
|
expect(rows[2]!.classList.contains('proj-commit-boundary')).toBe(true)
|
|
})
|
|
|
|
it('draws no boundary when nothing is unpushed', () => {
|
|
const host = document.createElement('div')
|
|
renderGitLog(host, {
|
|
commits: [entry('aaa1111', 'pushed')],
|
|
truncated: false,
|
|
upstream: 'origin/main',
|
|
})
|
|
expect(host.querySelector('.proj-commit-boundary')).toBeNull()
|
|
expect(host.querySelector('.proj-commit-unpushed')).toBeNull()
|
|
})
|
|
|
|
it('draws no boundary without an upstream, even if rows claim to be unpushed', () => {
|
|
const host = document.createElement('div')
|
|
renderGitLog(host, { commits: [entry('aaa1111', 'local', true)], truncated: false })
|
|
expect(host.querySelector('.proj-commit-boundary')).toBeNull()
|
|
})
|
|
|
|
it('treats a non-true `unpushed` from the server as pushed', () => {
|
|
const norm = normalizeGitLog({
|
|
commits: [{ hash: 'aaa1111', at: 1, subject: 's', unpushed: 'yes' }],
|
|
truncated: false,
|
|
})
|
|
expect(norm!.commits[0]!.unpushed).toBeUndefined()
|
|
})
|
|
|
|
it('keeps a string upstream and drops a junk one', () => {
|
|
expect(normalizeGitLog({ commits: [], truncated: false, upstream: 'origin/x' })!.upstream).toBe(
|
|
'origin/x',
|
|
)
|
|
expect(normalizeGitLog({ commits: [], truncated: false, upstream: 42 })!.upstream).toBeUndefined()
|
|
})
|
|
})
|