Clicking a project's name opens an in-app detail view (back button returns to the grid) with: - header: name, path, branch, dirty - Branch / Worktrees: each `git worktree list` entry (branch or detached@head, main/current/locked/prunable tags, path) — a single-worktree repo shows its branch; a multi-worktree repo shows them all - Active sessions (N): detailed rows (status, devices watching, started-ago) with Open + Kill - the Claude/Codex/VS Code launcher row Backend: src/http/worktrees.ts (parseWorktrees pure + listWorktrees via execFile, no shell, best-effort); buildProjectDetail(cfg, path, liveSessions) in projects.ts (validates absolute existing dir, reuses branch/dirty/session helpers, uncached — detail is on-demand and wants fresh state); read-only GET /projects/detail?path= (no Origin guard, like /projects; 400 missing / 404 invalid). Types: WorktreeInfo, ProjectDetail. Frontend: detail view + navigation in projects.ts (grid<->detail), project name becomes a link, Kill via DELETE /live-sessions/:id. Tests +21 (452 total): worktree parser, buildProjectDetail (validation, non-git, session merge, real git-init worktree), renderProjectDetail. worktrees.ts 100% / projects.ts 93.8% cov. Verified in-browser: opened a Claude session, then the detail showed its branch+worktree and the live session with Open/Kill.
84 lines
2.6 KiB
TypeScript
84 lines
2.6 KiB
TypeScript
/**
|
|
* test/worktrees.test.ts — parseWorktrees (pure parser for `git worktree list
|
|
* --porcelain`). The git wrapper itself is covered via the projects-detail
|
|
* integration test in projects.test.ts.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest'
|
|
import { parseWorktrees } from '../src/http/worktrees.js'
|
|
|
|
const MAIN = [
|
|
'worktree /repo/main',
|
|
'HEAD 1234567890abcdef1234567890abcdef12345678',
|
|
'branch refs/heads/main',
|
|
'',
|
|
].join('\n')
|
|
|
|
describe('parseWorktrees', () => {
|
|
it('parses a single main worktree on a branch', () => {
|
|
const wts = parseWorktrees(MAIN, '/repo/main')
|
|
expect(wts).toHaveLength(1)
|
|
expect(wts[0]).toMatchObject({
|
|
path: '/repo/main',
|
|
branch: 'main',
|
|
head: '12345678', // first 8 of the sha
|
|
isMain: true,
|
|
isCurrent: true,
|
|
})
|
|
})
|
|
|
|
it('marks only the first record as main; flags the current path', () => {
|
|
const text = [
|
|
'worktree /repo/main',
|
|
'HEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
'branch refs/heads/main',
|
|
'',
|
|
'worktree /repo/wt/feature',
|
|
'HEAD bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
'branch refs/heads/feature/login',
|
|
'',
|
|
].join('\n')
|
|
const wts = parseWorktrees(text, '/repo/wt/feature')
|
|
expect(wts).toHaveLength(2)
|
|
expect(wts[0]).toMatchObject({ path: '/repo/main', branch: 'main', isMain: true, isCurrent: false })
|
|
expect(wts[1]).toMatchObject({
|
|
path: '/repo/wt/feature',
|
|
branch: 'feature/login', // refs/heads/ stripped, slashes kept
|
|
isMain: false,
|
|
isCurrent: true,
|
|
})
|
|
})
|
|
|
|
it('handles a detached HEAD (no branch)', () => {
|
|
const text = ['worktree /repo/x', 'HEAD cccccccccccccccccccccccccccccccccccccccc', 'detached', ''].join('\n')
|
|
const wts = parseWorktrees(text, '/other')
|
|
expect(wts[0]?.branch).toBeUndefined()
|
|
expect(wts[0]?.head).toBe('cccccccc')
|
|
})
|
|
|
|
it('captures locked and prunable flags', () => {
|
|
const text = [
|
|
'worktree /repo/locked',
|
|
'HEAD dddddddddddddddddddddddddddddddddddddddd',
|
|
'branch refs/heads/wip',
|
|
'locked',
|
|
'prunable gitdir file points to non-existent location',
|
|
'',
|
|
].join('\n')
|
|
const wts = parseWorktrees(text, '/x')
|
|
expect(wts[0]?.locked).toBe(true)
|
|
expect(wts[0]?.prunable).toBe(true)
|
|
})
|
|
|
|
it('parses the final record even without a trailing blank line', () => {
|
|
const text = ['worktree /repo/main', 'HEAD eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', 'branch refs/heads/main'].join(
|
|
'\n',
|
|
)
|
|
expect(parseWorktrees(text, '/repo/main')).toHaveLength(1)
|
|
})
|
|
|
|
it('returns [] for empty input', () => {
|
|
expect(parseWorktrees('', '/x')).toEqual([])
|
|
})
|
|
})
|