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.
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
/**
|
|
* test/http/git-log.test.ts (W3 quick-wins d) — recent-commit log parsing + getGitLog.
|
|
*
|
|
* Two layers:
|
|
* 1. parseGitLog fed canned NUL-record/US-field output — the deterministic core
|
|
* (empty → []; malformed record skipped; subject with tabs/newlines intact;
|
|
* subject truncated at the cap; truncated flag when records === max).
|
|
* 2. getGitLog against a REAL throwaway git repo in os.tmpdir (newest-first,
|
|
* n-clamping + truncated flag). Mirrors test/http/diff.test.ts.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import fs from 'node:fs/promises'
|
|
import { execFile } from 'node:child_process'
|
|
import { promisify } from 'node:util'
|
|
import {
|
|
parseGitLog,
|
|
getGitLog,
|
|
clampLogCount,
|
|
GIT_LOG_MAX,
|
|
GIT_LOG_DEFAULT,
|
|
} from '../../src/http/git-log.js'
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
const US = '\x1f'
|
|
const NUL = '\x00'
|
|
|
|
/** Build one NUL-terminated record: hash US ct US subject. */
|
|
function rec(hash: string, ct: number, subject: string): string {
|
|
return `${hash}${US}${ct}${US}${subject}${NUL}`
|
|
}
|
|
|
|
// ── clampLogCount ─────────────────────────────────────────────────────────────
|
|
|
|
describe('clampLogCount', () => {
|
|
it('defaults to GIT_LOG_DEFAULT for missing / non-numeric', () => {
|
|
expect(clampLogCount(undefined)).toBe(GIT_LOG_DEFAULT)
|
|
expect(clampLogCount('abc')).toBe(GIT_LOG_DEFAULT)
|
|
expect(clampLogCount(NaN)).toBe(GIT_LOG_DEFAULT)
|
|
})
|
|
|
|
it('clamps to [1, GIT_LOG_MAX]', () => {
|
|
expect(clampLogCount(0)).toBe(1)
|
|
expect(clampLogCount(-5)).toBe(1)
|
|
expect(clampLogCount(999)).toBe(GIT_LOG_MAX)
|
|
expect(clampLogCount(GIT_LOG_MAX)).toBe(GIT_LOG_MAX)
|
|
})
|
|
|
|
it('parses a numeric string and floors it', () => {
|
|
expect(clampLogCount('3')).toBe(3)
|
|
expect(clampLogCount(3.9)).toBe(3)
|
|
})
|
|
})
|
|
|
|
// ── parseGitLog (pure) ────────────────────────────────────────────────────────
|
|
|
|
describe('parseGitLog', () => {
|
|
it('returns [] for empty stdout', () => {
|
|
expect(parseGitLog('', 20)).toEqual({ commits: [], truncated: false })
|
|
})
|
|
|
|
it('parses one record into hash / at(ms) / subject', () => {
|
|
const out = parseGitLog(rec('abc1234', 1700000000, 'first commit'), 20)
|
|
expect(out.commits).toHaveLength(1)
|
|
expect(out.commits[0]).toEqual({
|
|
hash: 'abc1234',
|
|
at: 1700000000 * 1000,
|
|
subject: 'first commit',
|
|
})
|
|
expect(out.truncated).toBe(false)
|
|
})
|
|
|
|
it('preserves subjects containing tabs and newlines (US/NUL delimiters)', () => {
|
|
const nasty = 'fix:\ttabbed\nand newlined'
|
|
const out = parseGitLog(rec('h1', 1700000000, nasty), 20)
|
|
expect(out.commits[0]?.subject).toBe(nasty)
|
|
})
|
|
|
|
it('skips a malformed record missing a field', () => {
|
|
const good = rec('h1', 1700000000, 'ok')
|
|
const bad = `h2${US}onlytwo${NUL}` // only 2 fields
|
|
const out = parseGitLog(good + bad, 20)
|
|
expect(out.commits.map((c) => c.hash)).toEqual(['h1'])
|
|
})
|
|
|
|
it('skips a record with a non-numeric timestamp', () => {
|
|
const out = parseGitLog(rec('h1', NaN as unknown as number, 'x') + rec('h2', 1700000001, 'y'), 20)
|
|
expect(out.commits.map((c) => c.hash)).toEqual(['h2'])
|
|
})
|
|
|
|
it('truncates an over-long subject at the cap', () => {
|
|
const long = 'x'.repeat(1000)
|
|
const out = parseGitLog(rec('h1', 1700000000, long), 20)
|
|
expect(out.commits[0]?.subject.length).toBe(500)
|
|
})
|
|
|
|
it('sets truncated when records === max (there may be more)', () => {
|
|
const stdout = rec('h1', 1, 'a') + rec('h2', 2, 'b') + rec('h3', 3, 'c')
|
|
const out = parseGitLog(stdout, 3)
|
|
expect(out.commits).toHaveLength(3)
|
|
expect(out.truncated).toBe(true)
|
|
})
|
|
|
|
it('does NOT set truncated when fewer records than max', () => {
|
|
const stdout = rec('h1', 1, 'a') + rec('h2', 2, 'b')
|
|
const out = parseGitLog(stdout, 3)
|
|
expect(out.truncated).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ── getGitLog (integration against a real git repo) ──────────────────────────
|
|
|
|
async function git(cwd: string, ...args: string[]): Promise<void> {
|
|
await execFileAsync('git', args, { cwd })
|
|
}
|
|
|
|
describe('getGitLog (real git repo)', () => {
|
|
let repo: string
|
|
|
|
beforeAll(async () => {
|
|
repo = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gitlog-'))
|
|
await git(repo, 'init', '-q', '-b', 'main')
|
|
await git(repo, 'config', 'user.email', 'test@example.com')
|
|
await git(repo, 'config', 'user.name', 'Test')
|
|
await git(repo, 'config', 'commit.gpgsign', 'false')
|
|
for (const [file, msg] of [
|
|
['a.txt', 'first'],
|
|
['b.txt', 'second'],
|
|
['c.txt', 'third'],
|
|
]) {
|
|
await fs.writeFile(path.join(repo, file), `${file}\n`)
|
|
await git(repo, 'add', '.')
|
|
await git(repo, 'commit', '-q', '-m', msg)
|
|
}
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await fs.rm(repo, { recursive: true, force: true })
|
|
})
|
|
|
|
it('returns 3 commits newest-first', async () => {
|
|
const out = await getGitLog(repo, { timeoutMs: 5000 })
|
|
expect(out.commits).toHaveLength(3)
|
|
expect(out.commits.map((c) => c.subject)).toEqual(['third', 'second', 'first'])
|
|
expect(out.truncated).toBe(false)
|
|
// hashes are short + non-empty; at is a plausible ms timestamp
|
|
for (const c of out.commits) {
|
|
expect(c.hash.length).toBeGreaterThan(0)
|
|
expect(c.at).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('clamps n and flags truncated when asking for fewer than exist', async () => {
|
|
const out = await getGitLog(repo, { n: 2, timeoutMs: 5000 })
|
|
expect(out.commits).toHaveLength(2)
|
|
expect(out.commits.map((c) => c.subject)).toEqual(['third', 'second'])
|
|
expect(out.truncated).toBe(true)
|
|
})
|
|
|
|
it('returns an empty result for a non-git directory (best-effort, no throw)', async () => {
|
|
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-nogit-'))
|
|
const out = await getGitLog(plain, { timeoutMs: 5000 })
|
|
expect(out).toEqual({ commits: [], truncated: false })
|
|
await fs.rm(plain, { recursive: true, force: true })
|
|
})
|
|
})
|
|
|
|
// ── w6/G4 unpushed marking ─────────────────────────────────────────────────────
|
|
// The marks come from `rev-list`, never from row position — see markUnpushed.
|
|
|
|
describe('getGitLog — unpushed marking (w6 G4)', { timeout: 30_000 }, () => {
|
|
async function git(cwd: string, args: string[]): Promise<string> {
|
|
const { stdout } = await execFileAsync('git', args, { cwd })
|
|
return stdout
|
|
}
|
|
|
|
async function initRepo(cwd: string): Promise<void> {
|
|
await fs.mkdir(cwd, { recursive: true })
|
|
await git(cwd, ['init', '-q', '-b', 'main'])
|
|
await git(cwd, ['config', 'user.email', 't@t.local'])
|
|
await git(cwd, ['config', 'user.name', 'tester'])
|
|
await git(cwd, ['config', 'commit.gpgsign', 'false'])
|
|
}
|
|
|
|
async function commit(cwd: string, file: string, msg: string): Promise<void> {
|
|
await fs.writeFile(path.join(cwd, file), `${file}\n`, 'utf8')
|
|
await git(cwd, ['add', '.'])
|
|
await git(cwd, ['commit', '-q', '-m', msg])
|
|
}
|
|
|
|
it('marks nothing and names no upstream when the branch tracks nothing', async () => {
|
|
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-noup-')))
|
|
await initRepo(repo)
|
|
await commit(repo, 'a.txt', 'one')
|
|
|
|
const res = await getGitLog(repo, { timeoutMs: 20_000 })
|
|
expect(res.commits.length).toBe(1)
|
|
expect(res.upstream).toBeUndefined()
|
|
expect(res.commits.every((c) => c.unpushed !== true)).toBe(true)
|
|
})
|
|
|
|
it('marks only the commits absent from the upstream and names it', async () => {
|
|
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-up-')))
|
|
const bare = path.join(root, 'bare.git')
|
|
await execFileAsync('git', ['init', '-q', '--bare', '-b', 'main', bare])
|
|
|
|
const repo = path.join(root, 'work')
|
|
await initRepo(repo)
|
|
await commit(repo, 'pushed.txt', 'pushed one')
|
|
await git(repo, ['remote', 'add', 'origin', bare])
|
|
await git(repo, ['push', '-q', '-u', 'origin', 'main'])
|
|
|
|
await commit(repo, 'local1.txt', 'local one')
|
|
await commit(repo, 'local2.txt', 'local two')
|
|
|
|
const res = await getGitLog(repo, { timeoutMs: 20_000 })
|
|
expect(res.upstream).toBe('origin/main')
|
|
const bySubject = new Map(res.commits.map((c) => [c.subject, c.unpushed === true]))
|
|
expect(bySubject.get('local two')).toBe(true)
|
|
expect(bySubject.get('local one')).toBe(true)
|
|
expect(bySubject.get('pushed one')).toBe(false)
|
|
})
|
|
|
|
it('marks an unpushed commit that sorts BELOW a pushed one (merged older branch)', async () => {
|
|
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitlog-merge-')))
|
|
const bare = path.join(root, 'bare.git')
|
|
await execFileAsync('git', ['init', '-q', '--bare', '-b', 'main', bare])
|
|
|
|
const repo = path.join(root, 'work')
|
|
await initRepo(repo)
|
|
await commit(repo, 'base.txt', 'base')
|
|
await git(repo, ['remote', 'add', 'origin', bare])
|
|
await git(repo, ['push', '-q', '-u', 'origin', 'main'])
|
|
|
|
// A side branch whose commit is BACKDATED so date-ordered `git log` puts it
|
|
// below the newer pushed tip — the exact shape "first N rows" gets wrong.
|
|
await git(repo, ['checkout', '-q', '-b', 'side'])
|
|
const old = '2001-01-01T00:00:00'
|
|
await fs.writeFile(path.join(repo, 'old.txt'), 'old\n', 'utf8')
|
|
await git(repo, ['add', '.'])
|
|
await execFileAsync(
|
|
'git',
|
|
['commit', '-q', '-m', 'backdated side work', '--date', old],
|
|
{ cwd: repo, env: { ...process.env, GIT_COMMITTER_DATE: old } },
|
|
)
|
|
await git(repo, ['checkout', '-q', 'main'])
|
|
await git(repo, ['merge', '-q', '--no-ff', '-m', 'merge side', 'side'])
|
|
|
|
const res = await getGitLog(repo, { timeoutMs: 20_000 })
|
|
const side = res.commits.find((c) => c.subject === 'backdated side work')
|
|
expect(side).toBeDefined()
|
|
expect(side!.unpushed).toBe(true)
|
|
|
|
// And it really is out of order, so the test is testing what it claims.
|
|
const idxSide = res.commits.findIndex((c) => c.subject === 'backdated side work')
|
|
const idxBase = res.commits.findIndex((c) => c.subject === 'base')
|
|
expect(idxSide).toBeGreaterThan(idxBase)
|
|
expect(res.commits[idxBase]!.unpushed).not.toBe(true)
|
|
})
|
|
})
|