/** * test/http/worktrees-create.test.ts — B3 git worktree creation (N-worktree). * * Covers the three new exports of src/http/worktrees.ts: * - validateBranchName (pure: git-ref subset rejection rules, SEC-H2) * - sanitizeBranchForDir (pure: branch → safe single dir segment) * - computeWorktreeDir (realpath-based containment, SEC-H3/M2) * - createWorktree (execFile, no shell, structured result, SEC-M10) * * Pure functions are unit-tested; createWorktree runs against real temporary * git repos (no network) — endpoint wiring is exercised separately in * T-server-wire's integration suite. */ import { describe, it, expect, beforeAll } 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 { validateBranchName, sanitizeBranchForDir, computeWorktreeDir, createWorktree, } from '../../src/http/worktrees.js' const execFileP = promisify(execFile) const TIMEOUT_MS = 10000 let gitAvailable = false beforeAll(async () => { try { await execFileP('git', ['--version']) gitAvailable = true } catch { gitAvailable = false } }) /** Create a temp git repo with one commit so `git worktree add -b` can run. */ async function makeRepo(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-repo-')) await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: dir }) await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: dir }) await execFileP('git', ['config', 'user.name', 'tester'], { cwd: dir }) await execFileP('git', ['commit', '-q', '--allow-empty', '-m', 'init'], { cwd: dir }) return dir } // ── validateBranchName ───────────────────────────────────────────────────────── describe('validateBranchName', () => { it('accepts ordinary branch names', () => { for (const ok of ['feature', 'feature/login', 'fix-bug', 'v1.2.3', 'foo_bar', 'a/b/c']) { expect(validateBranchName(ok)).toBe(true) } }) it('rejects empty and over-long names', () => { expect(validateBranchName('')).toBe(false) expect(validateBranchName('a'.repeat(251))).toBe(false) }) it('rejects a leading dash (would be parsed as a git flag, SEC-H2)', () => { expect(validateBranchName('-rf')).toBe(false) expect(validateBranchName('-b')).toBe(false) }) it('rejects path traversal and slash edge cases', () => { for (const bad of ['../x', 'foo..bar', '/leading', 'trailing/', 'double//slash']) { expect(validateBranchName(bad)).toBe(false) } }) it('rejects a trailing .lock', () => { expect(validateBranchName('foo.lock')).toBe(false) }) it('rejects whitespace and control characters', () => { expect(validateBranchName('has space')).toBe(false) expect(validateBranchName('tab\tx')).toBe(false) expect(validateBranchName('ctrl\x01x')).toBe(false) expect(validateBranchName('null\x00x')).toBe(false) }) it('rejects git-forbidden punctuation ~^:?*[\\ and @{', () => { for (const bad of ['a~b', 'a^b', 'a:b', 'a?b', 'a*b', 'a[b', 'back\\slash', 'foo@{1}']) { expect(validateBranchName(bad)).toBe(false) } }) }) // ── sanitizeBranchForDir ──────────────────────────────────────────────────────── describe('sanitizeBranchForDir', () => { it('replaces slashes with dashes', () => { expect(sanitizeBranchForDir('feature/login')).toBe('feature-login') expect(sanitizeBranchForDir('a/b/c')).toBe('a-b-c') }) it('trims leading/trailing dashes', () => { expect(sanitizeBranchForDir('/foo/')).toBe('foo') expect(sanitizeBranchForDir('-foo-')).toBe('foo') }) it('replaces filesystem-unsafe characters with a dash', () => { expect(sanitizeBranchForDir('a b')).toBe('a-b') expect(sanitizeBranchForDir('weird$name')).toBe('weird-name') }) it('preserves safe characters (alnum, dot, underscore, dash)', () => { expect(sanitizeBranchForDir('v1.2.3_rc')).toBe('v1.2.3_rc') }) }) // ── computeWorktreeDir ────────────────────────────────────────────────────────── describe('computeWorktreeDir', () => { it('places the worktree under an explicit root', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-root-')) const dir = await computeWorktreeDir('/repo', 'feature', root) expect(dir).toBe(path.join(root, 'feature')) }) it('defaults the base to -worktrees alongside the repo', async () => { const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-defbase-')) const dir = await computeWorktreeDir(repo, 'foo') expect(dir).toBe(path.join(path.dirname(repo), path.basename(repo) + '-worktrees', 'foo')) }) it('throws when the sanitized segment resolves outside the root (.., M2)', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-esc-')) await expect(computeWorktreeDir('/repo', '..', root)).rejects.toThrow() }) it('rejects a symlinked candidate that escapes the root after realpath (AC-B3.4)', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-symroot-')) const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-outside-')) // Pre-plant a symlink at the predicted candidate path pointing outside root. await fs.symlink(outside, path.join(root, 'feature')) await expect(computeWorktreeDir('/repo', 'feature', root)).rejects.toThrow() }) }) // ── createWorktree (real temp repos) ──────────────────────────────────────────── describe('createWorktree', () => { it('creates a new worktree + branch and returns its path', async () => { if (!gitAvailable) return const repo = await makeRepo() const res = await createWorktree(repo, 'feature/new', { timeoutMs: TIMEOUT_MS }) expect(res.ok).toBe(true) expect(res.branch).toBe('feature/new') expect(res.path).toBeDefined() const stat = await fs.stat(res.path as string) expect(stat.isDirectory()).toBe(true) const { stdout } = await execFileP('git', ['worktree', 'list', '--porcelain'], { cwd: repo }) expect(stdout).toContain(res.path as string) expect(stdout).toContain('branch refs/heads/feature/new') }) it('rejects an invalid branch name with 400 and never runs git', async () => { if (!gitAvailable) return const repo = await makeRepo() const res = await createWorktree(repo, '../evil', { timeoutMs: TIMEOUT_MS }) expect(res).toMatchObject({ ok: false, status: 400 }) // No worktree was created (still just the main one). const { stdout } = await execFileP('git', ['worktree', 'list'], { cwd: repo }) expect(stdout.trim().split('\n')).toHaveLength(1) }) it('returns 404 for a non-git directory', async () => { const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-plain-')) const res = await createWorktree(plain, 'feature', { timeoutMs: TIMEOUT_MS }) expect(res).toMatchObject({ ok: false, status: 404 }) }) it('returns 404 for a non-absolute repo path', async () => { const res = await createWorktree('relative/path', 'feature', { timeoutMs: TIMEOUT_MS }) expect(res).toMatchObject({ ok: false, status: 404 }) }) it('returns 409 when the branch already exists', async () => { if (!gitAvailable) return const repo = await makeRepo() const first = await createWorktree(repo, 'dup', { timeoutMs: TIMEOUT_MS }) expect(first.ok).toBe(true) const second = await createWorktree(repo, 'dup', { timeoutMs: TIMEOUT_MS }) expect(second).toMatchObject({ ok: false, status: 409 }) expect(second.error).toBeDefined() // Error message must not leak raw git stderr (SEC-M10). expect(second.error).not.toContain('fatal:') }) it('returns 400 when the computed dir escapes a symlinked root (M2)', async () => { if (!gitAvailable) return const repo = await makeRepo() const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-croot-')) const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'wt-cout-')) await fs.symlink(outside, path.join(root, 'feature')) const res = await createWorktree(repo, 'feature', { worktreeRoot: root, timeoutMs: TIMEOUT_MS }) expect(res).toMatchObject({ ok: false, status: 400 }) }) })