Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
The projects panel listed bare parent folders (~, ~/Documents) as projects and a single session lit up every ancestor project's 'Active now'. dropParentFolders filters non-git entries that merely contain other listed projects; assignSessions attributes each live session to the DEEPEST containing project (detail page keeps prefix matching). +3 tests; projects 29/29, tsc clean. Frontend unchanged.
491 lines
20 KiB
TypeScript
491 lines
20 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import fs from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { execFile } from 'node:child_process'
|
|
import { promisify } from 'node:util'
|
|
import type { Config, LiveSessionInfo } from '../src/types.js'
|
|
import type { HistorySession } from '../src/http/history.js'
|
|
|
|
// ── mock history.listSessions so the real ~/.claude/projects never pollutes ──────
|
|
// (vitest hoists vi.mock; the factory may only reference vars prefixed "mock".)
|
|
const mockListSessions = vi.fn(async (): Promise<HistorySession[]> => [])
|
|
vi.mock('../src/http/history.js', () => ({
|
|
listSessions: (...a: unknown[]) => mockListSessions(...a),
|
|
}))
|
|
|
|
const { parseGitHead, buildProjects, buildProjectDetail, _clearProjectCache } = await import(
|
|
'../src/http/projects.js'
|
|
)
|
|
|
|
const execFileP = promisify(execFile)
|
|
|
|
// ── helpers ─────────────────────────────────────────────────────────────────────
|
|
|
|
function makeCfg(overrides: Partial<Config>): Config {
|
|
return {
|
|
port: 3000,
|
|
bindHost: '0.0.0.0',
|
|
shellPath: '/bin/zsh',
|
|
homeDir: '/home/tester',
|
|
idleTtlMs: 1000,
|
|
scrollbackBytes: 1024,
|
|
maxPayloadBytes: 1024,
|
|
wsPath: '/term',
|
|
maxSessions: 50,
|
|
maxMsgsPerSec: 2000,
|
|
permTimeoutMs: 1000,
|
|
reapIntervalMs: 1000,
|
|
previewBytes: 1024,
|
|
useTmux: false,
|
|
allowedOrigins: [],
|
|
projectRoots: [],
|
|
projectScanDepth: 4,
|
|
projectScanTtlMs: 0, // default OFF so each test re-scans deterministically
|
|
projectDirtyCheck: false,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeLive(over: { id: string; cwd: string | null } & Partial<LiveSessionInfo>): LiveSessionInfo {
|
|
return {
|
|
id: over.id,
|
|
createdAt: over.createdAt ?? 1000,
|
|
clientCount: over.clientCount ?? 1,
|
|
status: over.status ?? 'unknown',
|
|
exited: over.exited ?? false,
|
|
cwd: over.cwd,
|
|
cols: over.cols ?? 80,
|
|
rows: over.rows ?? 24,
|
|
}
|
|
}
|
|
|
|
async function makeRepo(dir: string, branch: string): Promise<void> {
|
|
await fs.mkdir(path.join(dir, '.git'), { recursive: true })
|
|
await fs.writeFile(path.join(dir, '.git', 'HEAD'), `ref: refs/heads/${branch}\n`)
|
|
}
|
|
|
|
async function gitAvailable(): Promise<boolean> {
|
|
try {
|
|
await execFileP('git', ['--version'])
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
let tmp: string
|
|
|
|
beforeEach(async () => {
|
|
_clearProjectCache()
|
|
mockListSessions.mockReset()
|
|
mockListSessions.mockResolvedValue([])
|
|
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-'))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
_clearProjectCache()
|
|
await fs.rm(tmp, { recursive: true, force: true })
|
|
})
|
|
|
|
// ── parseGitHead (pure) ──────────────────────────────────────────────────────────
|
|
|
|
describe('parseGitHead', () => {
|
|
it('extracts a simple branch name', () => {
|
|
expect(parseGitHead('ref: refs/heads/main\n')).toBe('main')
|
|
})
|
|
|
|
it('extracts a branch name with slashes', () => {
|
|
expect(parseGitHead('ref: refs/heads/feature/x\n')).toBe('feature/x')
|
|
})
|
|
|
|
it('returns null for a detached HEAD (bare 40-hex sha)', () => {
|
|
expect(parseGitHead('a'.repeat(40) + '\n')).toBeNull()
|
|
})
|
|
|
|
it('returns null for empty / junk / non-heads refs', () => {
|
|
expect(parseGitHead('')).toBeNull()
|
|
expect(parseGitHead('garbage\n')).toBeNull()
|
|
expect(parseGitHead('ref: refs/tags/v1\n')).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ── buildProjects: discovery (real temp dir) ─────────────────────────────────────
|
|
|
|
describe('buildProjects — discovery', () => {
|
|
it('finds git repos with correct names/branches, skips node_modules, respects depth', async () => {
|
|
await makeRepo(path.join(tmp, 'repoA'), 'main') // depth 1
|
|
await makeRepo(path.join(tmp, 'sub', 'repoB'), 'dev') // depth 2
|
|
await makeRepo(path.join(tmp, 'sub', 'deep', 'repoC'), 'x') // depth 3 -> excluded
|
|
await makeRepo(path.join(tmp, 'node_modules', 'pkg'), 'y') // node_modules -> skipped
|
|
|
|
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
|
const out = await buildProjects(cfg, [])
|
|
const byName = new Map(out.map((p) => [p.name, p]))
|
|
|
|
expect([...byName.keys()].sort()).toEqual(['repoA', 'repoB'])
|
|
expect(byName.get('repoA')).toMatchObject({
|
|
isGit: true,
|
|
branch: 'main',
|
|
path: path.join(tmp, 'repoA'),
|
|
sessions: [],
|
|
})
|
|
expect(byName.get('repoB')!.branch).toBe('dev')
|
|
expect(byName.has('repoC')).toBe(false) // too deep
|
|
expect(byName.has('pkg')).toBe(false) // node_modules
|
|
})
|
|
|
|
it('records a root that is itself a git repo and does not descend into it', async () => {
|
|
await makeRepo(tmp, 'main')
|
|
await makeRepo(path.join(tmp, 'nested'), 'dev') // inside a repo -> not descended
|
|
|
|
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 4, projectDirtyCheck: false })
|
|
const out = await buildProjects(cfg, [])
|
|
|
|
expect(out.map((p) => p.path)).toEqual([tmp])
|
|
expect(out[0]!.branch).toBe('main')
|
|
})
|
|
|
|
it('leaves branch undefined when .git/HEAD is missing/unreadable', async () => {
|
|
await fs.mkdir(path.join(tmp, 'bare', '.git'), { recursive: true }) // .git dir, no HEAD
|
|
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
|
const out = await buildProjects(cfg, [])
|
|
const bare = out.find((p) => p.name === 'bare')!
|
|
expect(bare.isGit).toBe(true)
|
|
expect(bare.branch).toBeUndefined()
|
|
})
|
|
|
|
it('does not follow symlinks', async () => {
|
|
const other = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-link-'))
|
|
try {
|
|
await makeRepo(path.join(other, 'repoS'), 'main')
|
|
try {
|
|
await fs.symlink(other, path.join(tmp, 'linked'), 'dir')
|
|
} catch {
|
|
return // symlinks not permitted in this environment
|
|
}
|
|
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 4, projectDirtyCheck: false })
|
|
const out = await buildProjects(cfg, [])
|
|
expect(out.some((p) => p.name === 'repoS')).toBe(false)
|
|
} finally {
|
|
await fs.rm(other, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('returns [] for an unreadable / missing root (never throws)', async () => {
|
|
const cfg = makeCfg({
|
|
projectRoots: [path.join(tmp, 'does-not-exist')],
|
|
projectScanDepth: 2,
|
|
projectDirtyCheck: false,
|
|
})
|
|
await expect(buildProjects(cfg, [])).resolves.toEqual([])
|
|
})
|
|
})
|
|
|
|
// ── buildProjects: live-session merge ────────────────────────────────────────────
|
|
|
|
describe('buildProjects — session merge', () => {
|
|
it('merges sessions into the owning project by cwd === path or under path', async () => {
|
|
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
|
const repoPath = path.join(tmp, 'repoA')
|
|
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
|
|
|
const live: LiveSessionInfo[] = [
|
|
makeLive({ id: 's-exact', cwd: repoPath, status: 'working', clientCount: 2, createdAt: 111 }),
|
|
makeLive({ id: 's-under', cwd: path.join(repoPath, 'src', 'http'), status: 'idle' }),
|
|
makeLive({ id: 's-other', cwd: path.join(tmp, 'elsewhere') }),
|
|
makeLive({ id: 's-null', cwd: null }),
|
|
]
|
|
|
|
const out = await buildProjects(cfg, live)
|
|
const repoA = out.find((p) => p.name === 'repoA')!
|
|
|
|
expect(repoA.sessions.map((s) => s.id).sort()).toEqual(['s-exact', 's-under'])
|
|
expect(repoA.sessions.find((s) => s.id === 's-exact')).toEqual({
|
|
id: 's-exact',
|
|
title: 'repoA',
|
|
status: 'working',
|
|
clientCount: 2,
|
|
createdAt: 111,
|
|
exited: false,
|
|
})
|
|
expect(repoA.sessions.find((s) => s.id === 's-under')!.title).toBe('http')
|
|
})
|
|
|
|
it('attributes a session only to the DEEPEST project containing its cwd', async () => {
|
|
// repoA is discovered by the scan; the nested repoA/sub repo enters via
|
|
// history (scan does not descend into repos). A session inside sub must
|
|
// NOT also light up the ancestor repoA.
|
|
const repoPath = path.join(tmp, 'repoA')
|
|
const subPath = path.join(repoPath, 'sub')
|
|
await makeRepo(repoPath, 'main')
|
|
await makeRepo(subPath, 'dev')
|
|
mockListSessions.mockResolvedValue([
|
|
{ id: 'h1', cwd: subPath, project: 'sub', mtimeMs: 1000, preview: '' },
|
|
])
|
|
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
|
|
|
const live = [makeLive({ id: 's-deep', cwd: path.join(subPath, 'src') })]
|
|
const out = await buildProjects(cfg, live)
|
|
|
|
const repoA = out.find((p) => p.path === repoPath)!
|
|
const sub = out.find((p) => p.path === subPath)!
|
|
expect(sub.sessions.map((s) => s.id)).toEqual(['s-deep'])
|
|
expect(repoA.sessions).toEqual([])
|
|
})
|
|
|
|
it('re-merges live sessions FRESH on every call (discovery is cached)', async () => {
|
|
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
|
const repoPath = path.join(tmp, 'repoA')
|
|
const cfg = makeCfg({
|
|
projectRoots: [tmp],
|
|
projectScanDepth: 2,
|
|
projectDirtyCheck: false,
|
|
projectScanTtlMs: 60_000, // keep discovery cached across calls
|
|
})
|
|
|
|
const first = await buildProjects(cfg, [])
|
|
expect(first.find((p) => p.name === 'repoA')!.sessions).toEqual([])
|
|
|
|
const second = await buildProjects(cfg, [makeLive({ id: 's1', cwd: repoPath })])
|
|
expect(second.find((p) => p.name === 'repoA')!.sessions.map((s) => s.id)).toEqual(['s1'])
|
|
|
|
const third = await buildProjects(cfg, [])
|
|
expect(third.find((p) => p.name === 'repoA')!.sessions).toEqual([])
|
|
})
|
|
})
|
|
|
|
// ── buildProjects: history merge + sorting ───────────────────────────────────────
|
|
|
|
describe('buildProjects — history merge & sorting', () => {
|
|
it('adds history-only cwds as projects and sets lastActiveMs on discovered repos', async () => {
|
|
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
|
const repoPath = path.join(tmp, 'repoA')
|
|
const histDir = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-hist-'))
|
|
try {
|
|
mockListSessions.mockResolvedValue([
|
|
{ id: 'h1', cwd: repoPath, project: 'repoA', mtimeMs: 5000, preview: '' },
|
|
{ id: 'h2', cwd: repoPath, project: 'repoA', mtimeMs: 9000, preview: '' },
|
|
{ id: 'h3', cwd: histDir, project: path.basename(histDir), mtimeMs: 3000, preview: '' },
|
|
{ id: 'h4', cwd: '', project: 'unknown', mtimeMs: 1, preview: '' },
|
|
])
|
|
|
|
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
|
const out = await buildProjects(cfg, [])
|
|
|
|
const repoA = out.find((p) => p.name === 'repoA')!
|
|
expect(repoA.lastActiveMs).toBe(9000) // newest of h1/h2
|
|
|
|
const hist = out.find((p) => p.path === histDir)!
|
|
expect(hist).toBeDefined()
|
|
expect(hist.isGit).toBe(false)
|
|
expect(hist.lastActiveMs).toBe(3000)
|
|
|
|
expect(out.some((p) => p.path === '')).toBe(false) // empty cwd ignored
|
|
expect(out[0]!.name).toBe('repoA') // 9000 > 3000
|
|
} finally {
|
|
await fs.rm(histDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('drops non-git parent folders that merely contain other projects', async () => {
|
|
// Running a session in ~/Documents once puts it in history; it must not be
|
|
// listed as a project when it only contains real repos (parent folder).
|
|
const repoPath = path.join(tmp, 'repoA')
|
|
await makeRepo(repoPath, 'main')
|
|
mockListSessions.mockResolvedValue([
|
|
{ id: 'h1', cwd: tmp, project: path.basename(tmp), mtimeMs: 2000, preview: '' },
|
|
{ id: 'h2', cwd: repoPath, project: 'repoA', mtimeMs: 1000, preview: '' },
|
|
])
|
|
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
|
|
|
const out = await buildProjects(cfg, [])
|
|
|
|
expect(out.some((p) => p.path === tmp)).toBe(false) // parent folder dropped
|
|
expect(out.some((p) => p.path === repoPath)).toBe(true)
|
|
})
|
|
|
|
it('keeps a non-git history project that contains no other project', async () => {
|
|
const standalone = path.join(tmp, 'notes')
|
|
await fs.mkdir(standalone, { recursive: true })
|
|
mockListSessions.mockResolvedValue([
|
|
{ id: 'h1', cwd: standalone, project: 'notes', mtimeMs: 2000, preview: '' },
|
|
])
|
|
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
|
|
|
const out = await buildProjects(cfg, [])
|
|
|
|
expect(out.some((p) => p.path === standalone)).toBe(true)
|
|
})
|
|
|
|
it('sorts by lastActiveMs desc, undefined last, name asc tiebreak', async () => {
|
|
await makeRepo(path.join(tmp, 'zzz'), 'main') // no history -> undefined
|
|
await makeRepo(path.join(tmp, 'aaa'), 'main') // no history -> undefined
|
|
await makeRepo(path.join(tmp, 'mmm'), 'main') // has history -> first
|
|
mockListSessions.mockResolvedValue([
|
|
{ id: 'h', cwd: path.join(tmp, 'mmm'), project: 'mmm', mtimeMs: 1000, preview: '' },
|
|
])
|
|
|
|
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
|
const out = await buildProjects(cfg, [])
|
|
|
|
expect(out.map((p) => p.name)).toEqual(['mmm', 'aaa', 'zzz'])
|
|
})
|
|
})
|
|
|
|
// ── buildProjects: in-flight promise deduplication (Finding 4) ─────────────────────
|
|
|
|
describe('buildProjects — concurrent cache-miss deduplication', () => {
|
|
it('two concurrent cold-cache calls return consistent results (no corrupt state)', async () => {
|
|
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
|
await makeRepo(path.join(tmp, 'repoB'), 'dev')
|
|
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
|
|
|
// Both calls start concurrently while cache is cold.
|
|
const [a, b] = await Promise.all([buildProjects(cfg, []), buildProjects(cfg, [])])
|
|
|
|
const aNames = a.map((p) => p.name).sort()
|
|
const bNames = b.map((p) => p.name).sort()
|
|
expect(aNames).toEqual(['repoA', 'repoB'])
|
|
expect(bNames).toEqual(aNames)
|
|
})
|
|
|
|
it('second concurrent call gets the same result as the first (in-flight reuse)', async () => {
|
|
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
|
const cfg = makeCfg({
|
|
projectRoots: [tmp],
|
|
projectScanDepth: 2,
|
|
projectDirtyCheck: false,
|
|
projectScanTtlMs: 60_000,
|
|
})
|
|
|
|
// Start first call, immediately start second — second should share the in-flight promise.
|
|
const first = buildProjects(cfg, [])
|
|
const second = buildProjects(cfg, []) // cache still cold at this synchronous point
|
|
const [r1, r2] = await Promise.all([first, second])
|
|
expect(r1.map((p) => p.name)).toContain('repoA')
|
|
expect(r2.map((p) => p.name)).toEqual(r1.map((p) => p.name))
|
|
})
|
|
})
|
|
|
|
// ── buildProjects: dirty check (real git, when available) ─────────────────────────
|
|
|
|
describe('buildProjects — dirty check', () => {
|
|
it('computes dirty via git status only when projectDirtyCheck is true', async () => {
|
|
if (!(await gitAvailable())) return // git not installed -> skip
|
|
const repo = path.join(tmp, 'gitrepo')
|
|
await fs.mkdir(repo, { recursive: true })
|
|
await execFileP('git', ['init', '-q'], { cwd: repo })
|
|
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: repo })
|
|
await execFileP('git', ['config', 'user.name', 'tester'], { cwd: repo })
|
|
|
|
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: true })
|
|
|
|
// clean repo -> dirty false
|
|
let out = await buildProjects(cfg, [])
|
|
const clean = out.find((p) => p.name === 'gitrepo')!
|
|
expect(clean.dirty).toBe(false)
|
|
expect(clean.branch).toBeTruthy()
|
|
|
|
// untracked file -> dirty true
|
|
await fs.writeFile(path.join(repo, 'a.txt'), 'hi')
|
|
_clearProjectCache()
|
|
out = await buildProjects(cfg, [])
|
|
expect(out.find((p) => p.name === 'gitrepo')!.dirty).toBe(true)
|
|
})
|
|
|
|
it('never spawns git (dirty undefined) when projectDirtyCheck is false', async () => {
|
|
await makeRepo(path.join(tmp, 'repoA'), 'main')
|
|
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
|
|
const out = await buildProjects(cfg, [])
|
|
expect(out.find((p) => p.name === 'repoA')!.dirty).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
// ── buildProjectDetail ───────────────────────────────────────────────────────────
|
|
|
|
describe('buildProjectDetail', () => {
|
|
it('returns null for a relative path', async () => {
|
|
expect(await buildProjectDetail(makeCfg({}), 'relative/dir', [])).toBeNull()
|
|
})
|
|
|
|
it('returns null for a non-existent path', async () => {
|
|
expect(await buildProjectDetail(makeCfg({}), path.join(tmp, 'nope'), [])).toBeNull()
|
|
})
|
|
|
|
it('returns null when the path is a file', async () => {
|
|
const f = path.join(tmp, 'file.txt')
|
|
await fs.writeFile(f, 'x')
|
|
expect(await buildProjectDetail(makeCfg({}), f, [])).toBeNull()
|
|
})
|
|
|
|
it('reports a non-git directory with no worktrees', async () => {
|
|
const dir = path.join(tmp, 'plain')
|
|
await fs.mkdir(dir)
|
|
const d = await buildProjectDetail(makeCfg({}), dir, [])
|
|
expect(d).not.toBeNull()
|
|
expect(d!.isGit).toBe(false)
|
|
expect(d!.worktrees).toEqual([])
|
|
expect(d!.name).toBe('plain')
|
|
})
|
|
|
|
it('reads the branch for a git repo (worktrees best-effort)', async () => {
|
|
const dir = path.join(tmp, 'repo')
|
|
await makeRepo(dir, 'develop')
|
|
const d = await buildProjectDetail(makeCfg({}), dir, [])
|
|
expect(d!.isGit).toBe(true)
|
|
expect(d!.branch).toBe('develop')
|
|
expect(Array.isArray(d!.worktrees)).toBe(true) // real `git` fails on the fake .git → []
|
|
})
|
|
|
|
it('merges running sessions whose cwd is the project or under it', async () => {
|
|
const dir = path.join(tmp, 'repo')
|
|
await fs.mkdir(dir)
|
|
const live = [
|
|
makeLive({ id: 'a', cwd: dir, status: 'working' }),
|
|
makeLive({ id: 'b', cwd: path.join(dir, 'sub') }),
|
|
makeLive({ id: 'c', cwd: path.join(tmp, 'other') }),
|
|
]
|
|
const d = await buildProjectDetail(makeCfg({}), dir, live)
|
|
expect(d!.sessions.map((s) => s.id).sort()).toEqual(['a', 'b'])
|
|
})
|
|
|
|
it('includes CLAUDE.md content when present', async () => {
|
|
const dir = path.join(tmp, 'withmd')
|
|
await fs.mkdir(dir)
|
|
await fs.writeFile(path.join(dir, 'CLAUDE.md'), '# Project\nbe excellent')
|
|
const d = await buildProjectDetail(makeCfg({}), dir, [])
|
|
expect(d!.hasClaudeMd).toBe(true)
|
|
expect(d!.claudeMd).toContain('be excellent')
|
|
})
|
|
|
|
it('reports no CLAUDE.md when absent', async () => {
|
|
const dir = path.join(tmp, 'nomd')
|
|
await fs.mkdir(dir)
|
|
const d = await buildProjectDetail(makeCfg({}), dir, [])
|
|
expect(d!.hasClaudeMd).toBe(false)
|
|
expect(d!.claudeMd).toBeUndefined()
|
|
})
|
|
|
|
it('lists worktrees for a real git repo (gated on git)', async () => {
|
|
if (!(await gitAvailable())) return
|
|
await fs.mkdir(path.join(tmp, 'realrepo'))
|
|
// git canonicalises paths (/var → /private/var on macOS), so pass the
|
|
// realpath — real project paths from discovery are already canonical.
|
|
const dir = await fs.realpath(path.join(tmp, 'realrepo'))
|
|
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: dir })
|
|
await execFileP('git', ['config', 'user.email', 't@t'], { cwd: dir })
|
|
await execFileP('git', ['config', 'user.name', 't'], { cwd: dir })
|
|
await fs.writeFile(path.join(dir, 'f.txt'), 'hi')
|
|
await execFileP('git', ['add', '.'], { cwd: dir })
|
|
await execFileP('git', ['commit', '-q', '-m', 'init'], { cwd: dir })
|
|
|
|
const d = await buildProjectDetail(makeCfg({ projectDirtyCheck: true }), dir, [])
|
|
expect(d!.isGit).toBe(true)
|
|
expect(d!.branch).toBe('main')
|
|
expect(d!.worktrees.length).toBeGreaterThanOrEqual(1)
|
|
expect(d!.worktrees[0]).toMatchObject({ isMain: true, isCurrent: true, branch: 'main' })
|
|
expect(d!.dirty).toBe(false)
|
|
})
|
|
})
|