Files
Codeman/test/live-worktree-outside.test.ts
T
Michael GrundbergandClaude Opus 5.5 e2359aa0e8 test(files): cover the review's two remaining outside-worktree cases
The symlinked .git folder test now also runs from a session in the main
checkout, as well as from a linked worktree. A new pane-watcher test
checks that an unparseable docker-cases.json follows nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:13:45 +01:00

248 lines
11 KiB
TypeScript
Raw Permalink 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 `findLiveWorktree()` for a worktree OUTSIDE the session's folder (#765 follow-up).
*
* Such a worktree becomes a second root the file routes serve, so what matters is what must NOT be
* followed: a folder the session's repository does not list, a forged `.git` file with no registry
* entry naming it back, another repository, the home folder or anything above it, a folder holding
* the session's folder, a Docker case workspace, and a folder the multi-user rule refuses. Runs
* against worktrees real git made, since the registry files are the thing being read.
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, relative as posixRelative } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { findLiveWorktree, findOutsideWorktree } from '../src/live-worktree.js';
const git = (cwd: string, ...args: string[]): void => {
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd, stdio: 'ignore' });
};
describe('findLiveWorktree outside the session folder', () => {
let base: string;
let repo: string;
let sibling: string;
/** Not the user's real home: the base folder must not count as "home or above". */
let home: string;
beforeEach(() => {
base = realpathSync(mkdtempSync(join(tmpdir(), 'outside-worktree-')));
home = join(base, 'home');
mkdirSync(home);
repo = join(base, 'myapp');
mkdirSync(repo);
git(repo, 'init', '-q');
writeFileSync(join(repo, 'README.md'), 'main');
git(repo, 'add', 'README.md');
git(repo, 'commit', '-q', '-m', 'init');
sibling = join(base, 'myapp-feature');
git(repo, 'worktree', 'add', '-q', sibling);
mkdirSync(join(sibling, 'src', 'deep'), { recursive: true });
});
afterEach(() => {
rmSync(base, { recursive: true, force: true });
});
const expected = () => ({ name: 'myapp-feature', path: sibling, outside: true });
it('follows a sibling worktree of the session repository, from its root and from below it', async () => {
expect(await findLiveWorktree(repo, sibling, { home })).toEqual(expected());
expect(await findLiveWorktree(repo, join(sibling, 'src', 'deep'), { home })).toEqual(expected());
});
it('follows nothing outside the folder unless the caller passes the outside rules', async () => {
expect(await findLiveWorktree(repo, sibling)).toBeUndefined();
});
it('follows a worktree whose gitdir links are relative paths, whatever the git version', async () => {
// Hand-written, since git older than 2.48 cannot make this layout itself.
const relative = join(base, 'myapp-relative');
const entry = join(repo, '.git', 'worktrees', 'myapp-relative');
mkdirSync(relative);
mkdirSync(entry, { recursive: true });
writeFileSync(join(entry, 'gitdir'), `${posixRelative(entry, join(relative, '.git'))}\n`);
writeFileSync(join(relative, '.git'), `gitdir: ${posixRelative(relative, entry)}\n`);
expect(await findLiveWorktree(repo, relative, { home })).toEqual({
name: 'myapp-relative',
path: relative,
outside: true,
});
});
it('follows a worktree made with --relative-paths', async (ctx) => {
const relative = join(base, 'myapp-relative-git');
try {
git(repo, 'worktree', 'add', '-q', '--relative-paths', relative);
} catch {
ctx.skip(); // git older than 2.48 has no --relative-paths
return;
}
expect(await findLiveWorktree(repo, relative, { home })).toEqual({
name: 'myapp-relative-git',
path: relative,
outside: true,
});
});
it('follows the main worktree and other worktrees from a session started in a linked one', async () => {
const other = join(base, 'myapp-other');
git(repo, 'worktree', 'add', '-q', other);
expect(await findLiveWorktree(sibling, repo, { home })).toEqual({ name: 'myapp', path: repo, outside: true });
expect(await findLiveWorktree(sibling, other, { home })).toEqual({
name: 'myapp-other',
path: other,
outside: true,
});
});
it('answers with the real path when the pane reached the worktree through a symlink', async () => {
const link = join(base, 'link-to-feature');
symlinkSync(sibling, link);
expect(await findLiveWorktree(repo, join(link, 'src'), { home })).toEqual(expected());
});
it('refuses a folder in another repository, and one in no repository at all', async () => {
const otherRepo = join(base, 'other');
mkdirSync(otherRepo);
git(otherRepo, 'init', '-q');
expect(await findLiveWorktree(repo, otherRepo, { home })).toBeUndefined();
const plain = join(base, 'plain');
mkdirSync(plain);
expect(await findLiveWorktree(repo, plain, { home })).toBeUndefined();
});
it('refuses a worktree of ANOTHER repository, even one laid out the same way', async () => {
const otherRepo = join(base, 'other');
mkdirSync(otherRepo);
git(otherRepo, 'init', '-q');
writeFileSync(join(otherRepo, 'a'), 'a');
git(otherRepo, 'add', 'a');
git(otherRepo, 'commit', '-q', '-m', 'a');
const otherWorktree = join(base, 'other-feature');
git(otherRepo, 'worktree', 'add', '-q', otherWorktree);
expect(await findLiveWorktree(repo, otherWorktree, { home })).toBeUndefined();
});
it('refuses a forged .git file that the registry does not name back', async () => {
const forged = join(base, 'forged');
mkdirSync(forged);
// Points at the real registry entry of the sibling, whose `gitdir` names the sibling.
const entry = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: sibling, encoding: 'utf8' }).trim();
writeFileSync(join(forged, '.git'), `gitdir: ${entry}\n`);
expect(await findLiveWorktree(repo, forged, { home })).toBeUndefined();
});
it('refuses a registry entry that names a .git symlink back', async () => {
const entry = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: sibling, encoding: 'utf8' }).trim();
// The symlink leads to a copy of the original `gitdir` file, whose text is `<sibling>/.git`,
// so only the refusal of a symlink can stop it: followed, it would name the sibling back.
const copy = join(base, 'gitdir-copy');
writeFileSync(copy, readFileSync(join(entry, 'gitdir')));
rmSync(join(entry, 'gitdir'));
symlinkSync(copy, join(entry, 'gitdir'));
expect(await findLiveWorktree(repo, sibling, { home })).toBeUndefined();
});
it('refuses a .git file that is a symlink', async () => {
const text = execFileSync('cat', [join(sibling, '.git')], { encoding: 'utf8' });
const copy = join(base, 'dot-git-copy');
writeFileSync(copy, text);
rmSync(join(sibling, '.git'));
symlinkSync(copy, join(sibling, '.git'));
expect(await findLiveWorktree(repo, sibling, { home })).toBeUndefined();
});
it('refuses a folder whose .git is a symlink to the repository’s .git folder', async () => {
const fake = join(base, 'fake');
mkdirSync(fake);
symlinkSync(join(repo, '.git'), join(fake, '.git'));
// From a session in the main checkout.
expect(await findLiveWorktree(repo, fake, { home })).toBeUndefined();
// From a session in a linked worktree.
expect(await findLiveWorktree(sibling, fake, { home })).toBeUndefined();
// The real main worktree is still followed from the same session.
expect(await findLiveWorktree(sibling, repo, { home })).toEqual({ name: 'myapp', path: repo, outside: true });
});
it('follows nothing from a session started in a subfolder of its repository', async () => {
const sub = join(repo, 'sub');
mkdirSync(sub);
expect(await findLiveWorktree(sub, sibling, { home })).toBeUndefined();
});
it('refuses a worktree at or above the home folder', async () => {
expect(await findLiveWorktree(repo, sibling, { home: join(sibling, 'src') })).toBeUndefined();
expect(await findLiveWorktree(repo, sibling, { home: sibling })).toBeUndefined();
});
it('refuses a worktree that holds the session folder', async () => {
// A registry entry forged around the session's own folder: `base` holds `repo`. Both halves
// of the link are in place, so only the containment rule can refuse it.
const entry = join(repo, '.git', 'worktrees', 'around');
mkdirSync(entry, { recursive: true });
writeFileSync(join(entry, 'gitdir'), `${join(base, '.git')}\n`);
writeFileSync(join(base, '.git'), `gitdir: ${entry}\n`);
const elsewhereHome = realpathSync(mkdtempSync(join(tmpdir(), 'outside-worktree-home-')));
try {
expect(await findOutsideWorktree(repo, base, { home: elsewhereHome })).toBeUndefined();
// The same forged link around a folder that does NOT hold the session folder is followed,
// which shows the refusal above came from the containment rule.
const loose = join(base, 'loose');
mkdirSync(loose);
const looseEntry = join(repo, '.git', 'worktrees', 'loose');
mkdirSync(looseEntry, { recursive: true });
writeFileSync(join(looseEntry, 'gitdir'), `${join(loose, '.git')}\n`);
writeFileSync(join(loose, '.git'), `gitdir: ${looseEntry}\n`);
expect(await findOutsideWorktree(repo, loose, { home: elsewhereHome })).toEqual({
name: 'loose',
path: loose,
outside: true,
});
} finally {
rmSync(elsewhereHome, { recursive: true, force: true });
}
});
it('refuses a worktree at, inside or around a Docker case workspace', async () => {
const at = async () => [sibling];
const around = async () => [join(sibling, 'src')];
const above = async () => [base];
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: at })).toBeUndefined();
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: around })).toBeUndefined();
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: above })).toBeUndefined();
const elsewhere = async () => [join(base, 'elsewhere')];
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: elsewhere })).toEqual(expected());
});
it('follows nothing while the Docker case list cannot be read', async () => {
const unreadable = async (): Promise<string[]> => {
throw new Error('docker-cases.json mid-write');
};
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: unreadable })).toBeUndefined();
});
it('lets allowRoot refuse the worktree, and passes it the real path', async () => {
const seen: string[] = [];
const refuse = (root: string) => {
seen.push(root);
return false;
};
expect(await findLiveWorktree(repo, sibling, { home, allowRoot: refuse })).toBeUndefined();
expect(seen).toEqual([sibling]);
expect(await findLiveWorktree(repo, sibling, { home, allowRoot: async () => true })).toEqual(expected());
});
it('treats an allowRoot that rejects as a refusal', async () => {
const reject = async () => {
throw new Error('settings unreadable');
};
expect(await findLiveWorktree(repo, sibling, { home, allowRoot: reject })).toBeUndefined();
});
it('clears when the pane comes back to the session folder', async () => {
expect(await findLiveWorktree(repo, repo, { home })).toBeUndefined();
expect(await findLiveWorktree(repo, join(repo, 'src'), { home })).toBeUndefined();
});
});