Files
Codeman/test/routes/worktree-routes.test.ts
T
Codeman maintainer 517e12fe1d feat(worktrees): create, list and remove git worktrees of a local case
Sessions in one case share its folder, so several agents on one repository
edit, build and commit in the same checkout and step on each other. A case
whose folder is a git repository can now keep worktrees under
<repo>/.claude/worktrees/<name> on a new branch (default worktree-<name>),
which is Claude Code's own --worktree layout (measured in Claude Code
2.1.296) and the one Codeman's worktree pill already recognizes.

New module src/git-worktrees.ts (pure name/branch gates, worktree list
parsing, exclude text; IO through an injectable runner) and routes
GET/POST /api/cases/:name/worktrees and
DELETE /api/cases/:name/worktrees/:worktree:

- git runs as argv with -- before paths, non-interactive env, process
  group timeouts, --no-optional-locks on reads; names are gated before
  git and confirmed with git check-ref-format --branch.
- Only <repo>/.claude/worktrees/<valid-name> is created or removed; every
  path component is lstat'ed and realpath-checked, so a symlinked .claude
  cannot redirect a create or remove outside the repository.
- /.claude/worktrees/ (and the per-workspace hooks file Codeman writes,
  which otherwise blocks git worktree remove) go to the repository's
  local info/exclude, never a tracked .gitignore.
- Removal is git worktree remove WITHOUT --force, refused while a live
  session runs inside; branch delete is git branch -d, never -D.
- Docker and remote cases are refused with a 400, and git never runs for
  a case at or inside a Docker case workspace.

Starting a session reuses the existing create paths: quick-start takes
a worktree name (validated, local cases only) and runs hooks, the skill,
the model override and the session itself in the worktree;
POST /api/sessions takes the worktree as workingDir. The unified session
list names the worktree of a live session from its folder layout, so a
codex or shell session in one gets the worktree pill too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:16:00 +02:00

341 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview The worktree routes (`/api/cases/:name/worktrees`) and quick-start's `worktree`
* field, against a REAL temp git repository in the per-file temp HOME's case space
* (`test/setup.ts`). Covers the happy path end to end (create, list with live sessions, start a
* session in it, remove, branch delete) and every refusal: Docker and remote cases (git never
* spawned for Docker), a folder that is not a repository, bad names, a symlinked `.claude`, a
* dirty tree, a live session inside, and multi-user ownership. Port: N/A (app.inject()).
*/
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerWorktreeRoutes } from '../../src/web/routes/worktree-routes.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { CASES_DIR } from '../../src/web/route-helpers.js';
import { getDataDir } from '../../src/config/instance.js';
import { userCasesDir } from '../../src/config/multiuser.js';
import { Session } from '../../src/session.js';
import type { WorktreeGitRunner } from '../../src/git-worktrees.js';
const ENV = {
...process.env,
LC_ALL: 'C',
GIT_AUTHOR_NAME: 'T',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 'T',
GIT_COMMITTER_EMAIL: 't@example.com',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
};
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
/** A git repository with one commit at `dir`. */
function makeRepo(dir: string): string {
mkdirSync(dir, { recursive: true });
git(dir, 'init', '-q', '-b', 'main');
writeFileSync(join(dir, 'README.md'), '# repo\n');
git(dir, 'add', 'README.md');
git(dir, 'commit', '-q', '-m', 'init');
return realpathSync(dir);
}
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
/**
* The worktree routes plus the session routes (quick-start, POST /api/sessions), with the
* production envelope hook mirrored from server.ts: quick-start returns a bare object and relies
* on it for both the `{ success, data }` wrapper and the error-code status, as in
* case-clone-routes.test.ts.
*/
async function harness(
opts: { authUser?: { username: string; role: 'admin' | 'user' }; git?: WorktreeGitRunner } = {}
): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
if (opts.authUser) {
const authUser = opts.authUser;
app.addHook('onRequest', async (req) => {
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
});
}
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
const ctx = createMockRouteContext();
registerWorktreeRoutes(app, ctx as never, { git: opts.git });
registerSessionRoutes(app, ctx as never);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
let h: Harness;
let repo: string;
beforeEach(async () => {
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
repo = makeRepo(join(CASES_DIR, 'wtcase'));
h = await harness();
});
afterEach(async () => {
await h.app.close();
vi.restoreAllMocks();
delete process.env.CODEMAN_MULTIUSER;
await rm(join(getDataDir(), 'docker-hosts.json'), { force: true });
await rm(join(getDataDir(), 'docker-cases.json'), { force: true });
await rm(join(getDataDir(), 'remote-hosts.json'), { force: true });
await rm(join(getDataDir(), 'remote-cases.json'), { force: true });
await rm(join(getDataDir(), 'linked-cases.json'), { force: true });
safeRmHomeTree(CASES_DIR);
safeRmHomeTree(join(process.env.HOME ?? '/nonexistent', 'codeman-users'));
});
const list = (name = 'wtcase') => h.app.inject({ method: 'GET', url: `/api/cases/${name}/worktrees` });
const create = (payload: Record<string, unknown>, name = 'wtcase') =>
h.app.inject({ method: 'POST', url: `/api/cases/${name}/worktrees`, payload });
const remove = (wt: string, query = '', name = 'wtcase') =>
h.app.inject({ method: 'DELETE', url: `/api/cases/${name}/worktrees/${wt}${query}` });
describe('worktree routes: happy path', () => {
it('creates, lists, starts a session in, and removes a worktree', async () => {
const before = await list();
expect(before.statusCode).toBe(200);
expect(before.json().data).toMatchObject({ caseName: 'wtcase', repoRoot: repo, truncated: false });
expect(before.json().data.worktrees).toHaveLength(1);
const res = await create({ name: 'fix-login' });
expect(res.statusCode).toBe(201);
const wtPath = join(repo, '.claude', 'worktrees', 'fix-login');
expect(res.json().data.worktree).toMatchObject({ name: 'fix-login', path: wtPath, branch: 'worktree-fix-login' });
expect(git(repo, 'status', '--porcelain')).toBe('');
// Start a session in it through the EXISTING quick-start path.
const qs = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'wtcase', mode: 'claude', worktree: 'fix-login', sessionName: 'w1-fix-login' },
});
expect(qs.statusCode).toBe(200);
const qsData = qs.json().data;
expect(qsData).toMatchObject({ casePath: repo, worktree: 'fix-login', workingDir: wtPath });
const session = h.ctx.sessions.get(qsData.sessionId) as unknown as { workingDir: string };
expect(session.workingDir).toBe(wtPath);
// The session's hooks land in the worktree it runs in, not the case folder.
expect(readFileSync(join(wtPath, '.claude', 'settings.local.json'), 'utf-8')).toContain('/api/hook-event');
// ...and that file does not make the worktree dirty (it is excluded).
expect(git(wtPath, 'status', '--porcelain')).toBe('');
const listed = (await list()).json().data.worktrees;
const row = listed.find((w: { name?: string }) => w.name === 'fix-login');
expect(row).toMatchObject({ managed: true, branch: 'worktree-fix-login', sessionCount: 1 });
expect(row.sessions).toEqual([{ id: qsData.sessionId, name: 'w1-fix-login' }]);
// The main checkout does not count a session that runs in a worktree below it.
expect(listed[0]).toMatchObject({ main: true, isCase: true, managed: false });
// Refused while that session lives.
const busy = await remove('fix-login');
expect(busy.statusCode).toBe(409);
expect(busy.json().error).toContain('live session');
h.ctx.sessions.delete(qsData.sessionId);
const gone = await remove('fix-login', '?deleteBranch=true');
expect(gone.statusCode).toBe(200);
expect(gone.json().data.removed).toMatchObject({
name: 'fix-login',
branch: 'worktree-fix-login',
branchDeleted: true,
});
expect(existsSync(wtPath)).toBe(false);
expect(git(repo, 'branch', '--list', 'worktree-fix-login')).toBe('');
});
it('surfaces git’s refusal for a dirty tree and keeps the files', async () => {
await create({ name: 'dirty', branch: 'feat/dirty' });
const wtPath = join(repo, '.claude', 'worktrees', 'dirty');
writeFileSync(join(wtPath, 'wip.txt'), 'work');
const res = await remove('dirty');
expect(res.statusCode).toBe(409);
expect(res.json().error).toContain('contains modified or untracked files');
expect(readFileSync(join(wtPath, 'wip.txt'), 'utf-8')).toBe('work');
});
it('lets a POST /api/sessions create path use the worktree as workingDir', async () => {
await create({ name: 'viasessions' });
const wtPath = join(repo, '.claude', 'worktrees', 'viasessions');
const res = await h.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { workingDir: wtPath, mode: 'shell', name: 's1-viasessions' },
});
expect(res.statusCode).toBe(200);
expect(res.json().data.session.workingDir).toBe(wtPath);
});
});
describe('worktree routes: refusals', () => {
it('rejects bad names before git (schema and the finer rules)', async () => {
for (const name of ['-x', 'a/b', '.hidden', 'x'.repeat(65), 'a..b', 'n.lock']) {
const res = await create({ name });
expect(res.statusCode, name).toBe(400);
}
expect((await remove('a..b')).statusCode).toBe(400);
expect((await create({ name: 'ok', branch: '-D' })).statusCode).toBe(400);
expect((await create({ name: 'ok', base: '--upload-pack=sh' })).statusCode).toBe(400);
expect(git(repo, 'worktree', 'list')).not.toContain('ok');
});
it('answers 404 for a missing case and 400 for a folder that is not a repository', async () => {
expect((await list('nosuchcase')).statusCode).toBe(404);
mkdirSync(join(CASES_DIR, 'plain'), { recursive: true });
const res = await list('plain');
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('not a git repository');
});
it('refuses a Docker case with a 400 and never spawns git', async () => {
let spawned = 0;
const spy: WorktreeGitRunner = async () => {
spawned++;
return { code: 0, stdout: '', stderr: '', timedOut: false };
};
await h.app.close();
h = await harness({ git: spy });
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'docker-hosts.json'),
JSON.stringify([{ id: 'd1', label: 'box', image: 'codeman/agent:base' }])
);
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([{ name: 'dockcase', type: 'docker', hostId: 'd1', hostWorkspacePath: repo }])
);
for (const res of [
await list('dockcase'),
await create({ name: 'x' }, 'dockcase'),
await remove('x', '', 'dockcase'),
]) {
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Docker');
}
// The local case whose folder IS that Docker workspace is refused too (git would run there).
const viaLocal = await list('wtcase');
expect(viaLocal.statusCode).toBe(400);
expect(viaLocal.json().error).toContain('Docker');
expect(spawned).toBe(0);
const qs = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'dockcase', mode: 'shell', worktree: 'x' },
});
expect(qs.statusCode).toBe(400);
expect(qs.json().error).toContain('Docker');
});
it('refuses a remote case with a 400', async () => {
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
JSON.stringify([{ id: 'r1', label: 'far', host: 'far.example', username: 'u' }])
);
await writeFile(
join(getDataDir(), 'remote-cases.json'),
JSON.stringify([{ name: 'farcase', type: 'remote', hostId: 'r1', remotePath: '/srv/repo' }])
);
const res = await list('farcase');
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('remote');
const qs = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'farcase', mode: 'shell', worktree: 'x' },
});
expect(qs.statusCode).toBe(400);
expect(qs.json().error).toContain('remote');
});
it('refuses to create through a symlinked .claude directory', async () => {
const outside = join(CASES_DIR, 'outside');
mkdirSync(join(outside, 'worktrees'), { recursive: true });
symlinkSync(outside, join(repo, '.claude'));
const res = await create({ name: 'evil' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('symlink');
expect(existsSync(join(outside, 'worktrees', 'evil'))).toBe(false);
});
it('quick-start refuses an unregistered worktree and never scaffolds a missing case', async () => {
mkdirSync(join(repo, '.claude', 'worktrees', 'stray'), { recursive: true });
const stray = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'wtcase', mode: 'shell', worktree: 'stray' },
});
expect(stray.statusCode).toBe(404);
const missing = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'brandnew', mode: 'shell', worktree: 'x' },
});
expect(missing.statusCode).toBe(404);
expect(existsSync(join(CASES_DIR, 'brandnew'))).toBe(false);
const badName = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'wtcase', mode: 'shell', worktree: '../x' },
});
expect(badName.statusCode).toBe(400);
});
});
describe('worktree routes: multi-user ownership', () => {
it('resolves a non-admin’s own case space only, never a linked case or another user’s case', async () => {
process.env.CODEMAN_MULTIUSER = '1';
// A linked case is admin-only: a non-admin's lookup falls through to their own space.
await mkdir(getDataDir(), { recursive: true });
await writeFile(join(getDataDir(), 'linked-cases.json'), JSON.stringify({ linkedrepo: repo }));
makeRepo(join(userCasesDir('alice'), 'alicecase'));
await h.app.close();
h = await harness({ authUser: { username: 'bob', role: 'user' } });
expect((await list('linkedrepo')).statusCode).toBe(404);
expect((await list('alicecase')).statusCode).toBe(404);
expect((await create({ name: 'x' }, 'alicecase')).statusCode).toBe(404);
expect(existsSync(join(userCasesDir('alice'), 'alicecase', '.claude'))).toBe(false);
await h.app.close();
h = await harness({ authUser: { username: 'alice', role: 'user' } });
expect((await list('alicecase')).statusCode).toBe(200);
expect((await create({ name: 'mine' }, 'alicecase')).statusCode).toBe(201);
await h.app.close();
h = await harness({ authUser: { username: 'root', role: 'admin' } });
const linked = await list('linkedrepo');
expect(linked.statusCode).toBe(200);
expect(linked.json().data.repoRoot).toBe(repo);
});
});