mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge #772 fix(files): list the git worktree an agent moved into
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f8832ec78f
21 files changed
+1002
-36
No files matched your search
@@ -387,7 +387,7 @@ describe('File Viewer server search', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs');
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live');
|
||||
});
|
||||
|
||||
it('is a safe no-op without a panel or session and can lazily adopt a later active owner', async () => {
|
||||
@@ -399,7 +399,7 @@ describe('File Viewer server search', () => {
|
||||
app.activeSessionId = 'session/B';
|
||||
app.filterFileBrowser('after-session');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session');
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live');
|
||||
|
||||
pending[0].reply.resolve(response(successfulData()));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -450,7 +450,7 @@ describe('File Viewer server search', () => {
|
||||
expect(pending).toHaveLength(0);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -659,7 +659,7 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}`)).toBe(true);
|
||||
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live`)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects 257 trimmed characters without a request', async () => {
|
||||
@@ -786,7 +786,7 @@ describe('File Viewer server search', () => {
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts');
|
||||
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts');
|
||||
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true');
|
||||
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live');
|
||||
|
||||
pending[1].reply.resolve(response(successfulTree('new-preference.ts')));
|
||||
await toggle;
|
||||
@@ -794,7 +794,7 @@ describe('File Viewer server search', () => {
|
||||
expect(app._fileBrowserState.view).toBe('search-pending');
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget');
|
||||
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live');
|
||||
pending[2].reply.resolve(
|
||||
response(
|
||||
successfulData({
|
||||
@@ -877,8 +877,8 @@ describe('File Viewer server search', () => {
|
||||
const firstToggle = app.toggleFileBrowserHidden();
|
||||
const secondToggle = app.toggleFileBrowserHidden();
|
||||
expect(pending.slice(1).map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=true',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
]);
|
||||
|
||||
pending[1].reply.resolve(response(successfulTree('stale-hidden.ts')));
|
||||
@@ -891,7 +891,7 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending).toHaveLength(4);
|
||||
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget');
|
||||
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live');
|
||||
expect(app.fileBrowserShowHidden).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1674,7 +1674,7 @@ describe('File Viewer server search', () => {
|
||||
expect(immediateState.activeSessionId).toBe('session/B');
|
||||
expect(immediateState.ownerSessionId).toBe('session/B');
|
||||
expect(immediateState.resizeCalls).toBe(1);
|
||||
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
|
||||
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
|
||||
|
||||
expect(filePending).toHaveLength(1);
|
||||
expect(app._fileBrowserState.ownerSessionId).toBe('session/B');
|
||||
@@ -1755,7 +1755,7 @@ describe('File Viewer server search', () => {
|
||||
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
|
||||
expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0);
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
|
||||
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending).toHaveLength(1);
|
||||
@@ -1797,8 +1797,8 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live',
|
||||
]);
|
||||
expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true);
|
||||
});
|
||||
@@ -1815,9 +1815,9 @@ describe('File Viewer server search', () => {
|
||||
|
||||
expect(secondA).not.toBe(firstA);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
]);
|
||||
|
||||
pending[0].reply.resolve(response(successfulTree('stale-A.ts')));
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @fileoverview Files panel following the agent into a git worktree (#765).
|
||||
*
|
||||
* The server does the walking, so what can go wrong here is the client: asking for the wrong root,
|
||||
* showing a strip that does not describe the tree on screen, or reloading forever. The last one is
|
||||
* why the follow compares with what the listing reported rather than with the previous update.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context, like file-browser-hidden.test.ts.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
|
||||
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
|
||||
const WORKTREE = { name: 'probe', path: '/repo/.claude/worktrees/probe' };
|
||||
|
||||
function element() {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
hidden: true,
|
||||
value: '',
|
||||
classList: {
|
||||
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
|
||||
contains: (name: string) => classes.has(name),
|
||||
},
|
||||
setAttribute: () => {},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
function loadPanel() {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
escapeHtml: (s: string) => String(s),
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
fetch: undefined as unknown,
|
||||
CodemanBase: { url: (p: string) => p },
|
||||
});
|
||||
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
|
||||
|
||||
const ids = [
|
||||
'fileBrowserPanel',
|
||||
'fileBrowserTree',
|
||||
'fileBrowserStatus',
|
||||
'fileBrowserHiddenBtn',
|
||||
'fileBrowserSearch',
|
||||
'fileBrowserRoot',
|
||||
'fileBrowserRootLabel',
|
||||
'fileBrowserRootName',
|
||||
'fileBrowserRootSwitch',
|
||||
];
|
||||
const elements = Object.fromEntries(ids.map((id) => [id, element()]));
|
||||
elements.fileBrowserPanel.classList.toggle('visible', true);
|
||||
|
||||
const app = new CodemanApp();
|
||||
app.$ = (id: string) => elements[id] ?? null;
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.fileBrowserData = null;
|
||||
app.fileBrowserExpandedDirs = new Set<string>();
|
||||
app.fileBrowserFilter = '';
|
||||
app.fileBrowserShowHidden = false;
|
||||
app.getFileIcon = () => '';
|
||||
app.formatFileSize = () => '';
|
||||
|
||||
// A stand-in for the route: the session's worktree, listed when the request asks for it.
|
||||
const server = { worktree: null as typeof WORKTREE | null, requests: [] as string[] };
|
||||
context.fetch = async (url: string) => {
|
||||
server.requests.push(url);
|
||||
const live = url.includes('root=live') && server.worktree;
|
||||
const data = {
|
||||
root: live ? server.worktree!.path : '/repo',
|
||||
...(server.worktree && { worktree: server.worktree }),
|
||||
tree: [],
|
||||
totalFiles: 0,
|
||||
totalDirectories: 0,
|
||||
truncated: false,
|
||||
...(url.includes('q=') && { mode: 'search', matches: [], matchCount: 0 }),
|
||||
};
|
||||
return { ok: true, json: async () => ({ success: true, data }) };
|
||||
};
|
||||
return { app, elements, server };
|
||||
}
|
||||
|
||||
const strip = (elements: Record<string, ReturnType<typeof element>>) => ({
|
||||
hidden: elements.fileBrowserRoot.hidden,
|
||||
label: elements.fileBrowserRootLabel.textContent,
|
||||
name: elements.fileBrowserRootName.textContent,
|
||||
button: elements.fileBrowserRootSwitch.textContent,
|
||||
title: elements.fileBrowserRoot.title,
|
||||
});
|
||||
|
||||
describe('Files panel in a git worktree', () => {
|
||||
it('asks for the live root by default', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(server.requests[0]).toContain('root=live');
|
||||
});
|
||||
|
||||
it('shows no strip for a session in no worktree', async () => {
|
||||
const { app, elements } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(elements.fileBrowserRoot.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('says which worktree is listed, and offers the session folder', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(strip(elements)).toEqual({
|
||||
hidden: false,
|
||||
label: 'Worktree',
|
||||
name: 'probe',
|
||||
button: 'Session folder',
|
||||
title: WORKTREE.path,
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to the session folder and back', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
expect(server.requests[1]).not.toContain('root=live');
|
||||
expect(strip(elements)).toEqual({
|
||||
hidden: false,
|
||||
label: 'Session folder',
|
||||
name: '',
|
||||
button: 'Back to worktree',
|
||||
title: '/repo',
|
||||
});
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
expect(server.requests[2]).toContain('root=live');
|
||||
expect(elements.fileBrowserRootName.textContent).toBe('probe');
|
||||
});
|
||||
|
||||
it('keeps the query in the box across the switch, and searches the new root', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._fileBrowserState.filter = 'main';
|
||||
elements.fileBrowserSearch.value = 'main';
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
await vi.waitFor(() => expect(server.requests.some((url) => url.includes('q=main'))).toBe(true));
|
||||
|
||||
const search = server.requests.find((url) => url.includes('q=main'))!;
|
||||
expect(search).not.toContain('root=live');
|
||||
});
|
||||
|
||||
it('follows the agent into a worktree while the panel is open', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(server.requests).toHaveLength(1);
|
||||
|
||||
server.worktree = WORKTREE;
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
await vi.waitFor(() => expect(server.requests).toHaveLength(2));
|
||||
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(false));
|
||||
expect(elements.fileBrowserRootName.textContent).toBe('probe');
|
||||
});
|
||||
|
||||
it('follows the agent back out, and a pin on the old worktree does not outlive it', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
|
||||
server.worktree = null;
|
||||
app._followFileBrowserWorktree({ id: 'sess-1' });
|
||||
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(true));
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
|
||||
});
|
||||
|
||||
it('settles: updates that agree with the listing reload nothing', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
expect(server.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves another session and a closed panel alone', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._followFileBrowserWorktree({ id: 'other', liveWorktree: WORKTREE });
|
||||
elements.fileBrowserPanel.classList.toggle('visible', false);
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
expect(server.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops the pin and the strip on a session switch', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
|
||||
app._activateFileBrowserSession('sess-2');
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
|
||||
expect(elements.fileBrowserRoot.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('is wired up in the page', () => {
|
||||
expect(appJs).toContain('this._followFileBrowserWorktree?.(session)');
|
||||
expect(indexHtml).toContain('id="fileBrowserRoot" hidden');
|
||||
expect(indexHtml).toContain('onclick="app.toggleFileBrowserRoot()"');
|
||||
// The strip sets `display`, so `hidden` needs its own rule or it never hides.
|
||||
expect(stylesCss).toMatch(/\.file-browser-root\[hidden\]\s*\{\s*display: none;/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @fileoverview `findLiveWorktree()` (#765): which linked worktree a session's pane is in.
|
||||
*
|
||||
* The answer moves the Files panel's listing, so what matters is what it must NOT answer: the
|
||||
* session folder itself, a folder outside it, a plain subfolder (a `cd` in a shell must not re-root
|
||||
* the panel), a repository or submodule nested in the folder, and a `.git` that is not a regular
|
||||
* file. Runs against a real filesystem, and one case against a worktree real git made.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { findLiveWorktree, isLinkedWorktreeGitFile } from '../src/live-worktree.js';
|
||||
|
||||
describe('isLinkedWorktreeGitFile', () => {
|
||||
it('accepts what git writes for a linked worktree, absolute or relative', () => {
|
||||
expect(isLinkedWorktreeGitFile('gitdir: /home/me/repo/.git/worktrees/probe\n')).toBe(true);
|
||||
expect(isLinkedWorktreeGitFile('gitdir: ../../../.git/worktrees/probe\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a submodule, a bare path and text that is not a gitdir line', () => {
|
||||
expect(isLinkedWorktreeGitFile('gitdir: ../.git/modules/lib\n')).toBe(false);
|
||||
expect(isLinkedWorktreeGitFile('gitdir: worktrees\n')).toBe(false);
|
||||
expect(isLinkedWorktreeGitFile('/home/me/repo/.git/worktrees/probe\n')).toBe(false);
|
||||
expect(isLinkedWorktreeGitFile('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findLiveWorktree', () => {
|
||||
let base: string;
|
||||
let repo: string;
|
||||
let worktree: string;
|
||||
|
||||
beforeEach(() => {
|
||||
base = mkdtempSync(join(tmpdir(), 'live-worktree-'));
|
||||
repo = join(base, 'repo');
|
||||
worktree = join(repo, '.claude', 'worktrees', 'probe');
|
||||
mkdirSync(join(repo, '.git'), { recursive: true });
|
||||
mkdirSync(join(worktree, 'src', 'deep'), { recursive: true });
|
||||
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('finds the worktree from its own root and from below it', async () => {
|
||||
const expected = { name: 'probe', path: worktree };
|
||||
expect(await findLiveWorktree(repo, worktree)).toEqual(expected);
|
||||
expect(await findLiveWorktree(repo, join(worktree, 'src', 'deep'))).toEqual(expected);
|
||||
});
|
||||
|
||||
it('answers the same for a workingDir spelled with a trailing slash', async () => {
|
||||
expect(await findLiveWorktree(`${repo}/`, worktree)).toEqual({ name: 'probe', path: worktree });
|
||||
});
|
||||
|
||||
it('does not treat the session folder as a worktree of itself', async () => {
|
||||
writeFileSync(join(worktree, 'marker'), '');
|
||||
expect(await findLiveWorktree(worktree, worktree)).toBeUndefined();
|
||||
expect(await findLiveWorktree(worktree, join(worktree, 'src'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays on the session folder for a plain subfolder, so a shell cd moves nothing', async () => {
|
||||
mkdirSync(join(repo, 'src'));
|
||||
expect(await findLiveWorktree(repo, repo)).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, join(repo, 'src'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never answers outside the session folder', async () => {
|
||||
const outside = join(base, 'repo-feature');
|
||||
mkdirSync(outside);
|
||||
writeFileSync(join(outside, '.git'), `gitdir: ${repo}/.git/worktrees/feature\n`);
|
||||
// A sibling sharing the folder's name as a prefix is the case a bare startsWith gets wrong.
|
||||
expect(await findLiveWorktree(repo, outside)).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, '/')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stops at a submodule and at a repository of its own', async () => {
|
||||
const submodule = join(repo, 'lib');
|
||||
mkdirSync(submodule);
|
||||
writeFileSync(join(submodule, '.git'), 'gitdir: ../.git/modules/lib\n');
|
||||
expect(await findLiveWorktree(repo, submodule)).toBeUndefined();
|
||||
|
||||
const nested = join(worktree, 'vendored');
|
||||
mkdirSync(join(nested, '.git'), { recursive: true });
|
||||
expect(await findLiveWorktree(repo, nested)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a .git that is not a regular file', async () => {
|
||||
const linked = join(repo, 'linked');
|
||||
mkdirSync(linked);
|
||||
symlinkSync(join(worktree, '.git'), join(linked, '.git'));
|
||||
expect(await findLiveWorktree(repo, linked)).toBeUndefined();
|
||||
|
||||
// A FIFO would block open() for good: the test would time out rather than fail.
|
||||
const piped = join(repo, 'piped');
|
||||
mkdirSync(piped);
|
||||
execFileSync('mkfifo', [join(piped, '.git')]);
|
||||
expect(await findLiveWorktree(repo, piped)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('answers nothing without an absolute reading', async () => {
|
||||
expect(await findLiveWorktree(repo, undefined)).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, '')).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, '.claude/worktrees/probe')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recognises a worktree real git made', async () => {
|
||||
const real = join(base, 'real');
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', ...args], {
|
||||
cwd: real,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
mkdirSync(real);
|
||||
git('init', '-q');
|
||||
git('commit', '-q', '--allow-empty', '-m', 'init');
|
||||
git('worktree', 'add', '-q', '.claude/worktrees/made', '-b', 'worktree-made');
|
||||
expect(await findLiveWorktree(real, join(real, '.claude', 'worktrees', 'made'))).toEqual({
|
||||
name: 'made',
|
||||
path: join(real, '.claude', 'worktrees', 'made'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,11 +29,11 @@
|
||||
*
|
||||
* Port: ephemeral
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Session, rebasePath } from '../src/session.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import type { SessionDocker, SessionRemote } from '../src/types.js';
|
||||
@@ -278,6 +278,77 @@ describe('a pane read reaching the session record', () => {
|
||||
expect(session.toState().workingDir).toBe('/work/proj');
|
||||
});
|
||||
|
||||
describe('liveWorktree (#765)', () => {
|
||||
let base: string;
|
||||
let repo: string;
|
||||
let worktree: string;
|
||||
|
||||
beforeEach(() => {
|
||||
base = mkdtempSync(join(tmpdir(), 'pane-live-worktree-'));
|
||||
repo = join(base, 'repo');
|
||||
worktree = join(repo, '.claude', 'worktrees', 'probe');
|
||||
mkdirSync(worktree, { recursive: true });
|
||||
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const inRepo = () => {
|
||||
const { mux, sessions, web } = build();
|
||||
const session = localMuxSession({ workingDir: repo });
|
||||
sessions.set(session.id, session);
|
||||
const broadcast = vi.fn();
|
||||
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
|
||||
broadcast;
|
||||
mux.getPaneExit = () => undefined;
|
||||
return { mux, session, web, broadcast };
|
||||
};
|
||||
|
||||
it('publishes the worktree the pane moved into, and clears it when the pane leaves', async () => {
|
||||
const { mux, session, web, broadcast } = inRepo();
|
||||
let dir = worktree;
|
||||
mux.getPaneCurrentPath = () => dir;
|
||||
mux.emit('paneExitsUpdated');
|
||||
await vi.waitFor(() => expect(session.liveWorktree).toEqual({ name: 'probe', path: worktree }));
|
||||
// What the browser receives, and what the file routes read: never the confinement root.
|
||||
const state = (
|
||||
web as unknown as { getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown } }
|
||||
).getSessionStateWithRespawn(session);
|
||||
expect(state.liveWorktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(session.toState().workingDir).toBe(repo);
|
||||
|
||||
const broadcasts = broadcast.mock.calls.length;
|
||||
dir = repo;
|
||||
mux.emit('paneExitsUpdated');
|
||||
await vi.waitFor(() => expect(session.liveWorktree).toBeUndefined());
|
||||
expect(broadcast.mock.calls.length).toBeGreaterThan(broadcasts);
|
||||
});
|
||||
|
||||
it('does nothing for a pane that has not moved', async () => {
|
||||
const { mux, session } = inRepo();
|
||||
mux.getPaneCurrentPath = () => repo;
|
||||
const lookup = vi.spyOn(session, 'setLiveWorktree');
|
||||
mux.emit('paneExitsUpdated');
|
||||
await vi.waitFor(() => expect(lookup).toHaveBeenCalledTimes(1));
|
||||
mux.emit('paneExitsUpdated');
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(lookup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops an answer for a directory the pane has already left', async () => {
|
||||
const { web, session } = inRepo();
|
||||
session.setPaneCwd(worktree);
|
||||
const refresh = (
|
||||
web as unknown as { refreshLiveWorktree: (s: Session) => Promise<void> }
|
||||
).refreshLiveWorktree.call(web, session);
|
||||
session.setPaneCwd(repo);
|
||||
await refresh;
|
||||
expect(session.liveWorktree).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts a directory change once, and nothing on an unchanged tick', () => {
|
||||
const { mux, sessions } = build();
|
||||
const session = localMuxSession();
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @fileoverview `GET /api/sessions/:id/files?root=live` (#765).
|
||||
*
|
||||
* The listing starts at the worktree the pane is in, and every `path` stays relative to
|
||||
* `workingDir`: that is what keeps the preview, raw and edit routes working unchanged, inside the
|
||||
* same confinement. Real filesystem on purpose, since the walk is the thing under test.
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
|
||||
const PREFIX = '.claude/worktrees/probe';
|
||||
|
||||
describe('Files panel listing of a live worktree', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let repo: string;
|
||||
let worktree: string;
|
||||
/** Removed after each test: a folder of the server user's outside the session folder. */
|
||||
let outside: string | undefined;
|
||||
|
||||
const list = async (query = '') => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/files?depth=5${query}`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
return res.json().data;
|
||||
};
|
||||
const names = (nodes: Array<{ name: string }>) => nodes.map((node) => node.name);
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
repo = mkdtempSync(join(homedir(), 'live-worktree-route-'));
|
||||
worktree = join(repo, PREFIX);
|
||||
mkdirSync(join(repo, 'src'), { recursive: true });
|
||||
mkdirSync(join(worktree, 'src'), { recursive: true });
|
||||
writeFileSync(join(repo, 'src', 'main-only.ts'), 'main');
|
||||
writeFileSync(join(worktree, 'src', 'worktree-only.ts'), 'worktree');
|
||||
Object.assign(harness.ctx._session, {
|
||||
workingDir: repo,
|
||||
liveWorktree: { name: 'probe', path: worktree },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
if (outside) rmSync(outside, { recursive: true, force: true });
|
||||
outside = undefined;
|
||||
});
|
||||
|
||||
/** A folder outside the session folder, holding a visible and a hidden file and a subfolder. */
|
||||
const makeOutsideFolder = () => {
|
||||
outside = mkdtempSync(join(homedir(), 'live-worktree-outside-'));
|
||||
mkdirSync(join(outside, 'inner'));
|
||||
writeFileSync(join(outside, '.hidden-outside'), 'secret');
|
||||
writeFileSync(join(outside, 'inner', 'outside-file.txt'), 'secret');
|
||||
return outside;
|
||||
};
|
||||
const expectSessionFolderListing = (data: {
|
||||
root: string;
|
||||
worktree: unknown;
|
||||
tree: Array<{ name: string; children?: Array<{ path: string }> }>;
|
||||
}) => {
|
||||
expect(data.root).toBe(repo);
|
||||
expect(JSON.stringify(data.tree)).not.toContain('outside');
|
||||
expect(data.tree.find((node) => node.name === 'src')?.children?.[0].path).toBe('src/main-only.ts');
|
||||
// Kept so the panel's comparison with the session's state settles instead of reloading forever.
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
};
|
||||
|
||||
it('lists the worktree with paths relative to the session folder', async () => {
|
||||
const data = await list('&root=live');
|
||||
expect(data.root).toBe(worktree);
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(names(data.tree)).toEqual(['src']);
|
||||
expect(data.tree[0].path).toBe(`${PREFIX}/src`);
|
||||
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
|
||||
});
|
||||
|
||||
it('does not need hidden files on, though the worktree lives under a dot folder', async () => {
|
||||
const data = await list('&root=live&showHidden=false');
|
||||
expect(data.totalFiles).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the session folder without the parameter, and still reports the worktree', async () => {
|
||||
const data = await list();
|
||||
expect(data.root).toBe(repo);
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(names(data.tree)).toEqual(['src']);
|
||||
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
|
||||
});
|
||||
|
||||
it('answers exactly as before for a session in no worktree', async () => {
|
||||
Object.assign(harness.ctx._session, { liveWorktree: undefined });
|
||||
const data = await list('&root=live');
|
||||
expect(data).toEqual(await list());
|
||||
expect(data.root).toBe(repo);
|
||||
expect('worktree' in data).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the session folder when the worktree has gone, keeping the report', async () => {
|
||||
rmSync(worktree, { recursive: true });
|
||||
const data = await list('&root=live');
|
||||
expect(data.root).toBe(repo);
|
||||
// Kept so the panel's comparison with the session's state settles instead of reloading forever.
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
});
|
||||
|
||||
it('walks the session folder when a symlink replaced the worktree with a folder outside it', async () => {
|
||||
rmSync(worktree, { recursive: true });
|
||||
symlinkSync(makeOutsideFolder(), worktree);
|
||||
expectSessionFolderListing(await list('&root=live&showHidden=true'));
|
||||
});
|
||||
|
||||
it('does not find anything outside the session folder through such a symlink when searching', async () => {
|
||||
rmSync(worktree, { recursive: true });
|
||||
symlinkSync(makeOutsideFolder(), worktree);
|
||||
const data = await list(`&root=live&showHidden=true&q=${encodeURIComponent('outside')}`);
|
||||
expect(data.root).toBe(repo);
|
||||
expect(data.matches).toEqual([]);
|
||||
});
|
||||
|
||||
it('walks the session folder when a parent of the worktree is the symlink', async () => {
|
||||
const parent = join(repo, '.claude/worktrees');
|
||||
rmSync(parent, { recursive: true });
|
||||
mkdirSync(join(makeOutsideFolder(), 'probe'));
|
||||
writeFileSync(join(outside as string, 'probe', 'outside-file.txt'), 'secret');
|
||||
symlinkSync(outside as string, parent);
|
||||
expectSessionFolderListing(await list('&root=live&showHidden=true'));
|
||||
});
|
||||
|
||||
it('walks the session folder when the worktree path is a symlink back to that folder', async () => {
|
||||
rmSync(worktree, { recursive: true });
|
||||
symlinkSync(repo, worktree);
|
||||
expectSessionFolderListing(await list('&root=live'));
|
||||
});
|
||||
|
||||
it('still lists a worktree of a workspace reached through a symlink, in the workspace spelling', async () => {
|
||||
const link = `${repo}-link`;
|
||||
symlinkSync(repo, link);
|
||||
try {
|
||||
const spelled = join(link, PREFIX);
|
||||
Object.assign(harness.ctx._session, {
|
||||
workingDir: link,
|
||||
liveWorktree: { name: 'probe', path: spelled },
|
||||
});
|
||||
const data = await list('&root=live');
|
||||
expect(data.root).toBe(spelled);
|
||||
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
|
||||
} finally {
|
||||
rmSync(link);
|
||||
}
|
||||
});
|
||||
|
||||
it('searches the worktree: the query reads paths in the listing, the result in the session folder', async () => {
|
||||
const data = await list(`&root=live&q=${encodeURIComponent('src/*.ts')}`);
|
||||
expect(data.root).toBe(worktree);
|
||||
expect(data.matches.map((match: { path: string }) => match.path)).toEqual([`${PREFIX}/src/worktree-only.ts`]);
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
});
|
||||
|
||||
it('serves a listed path through the unchanged preview route', async () => {
|
||||
const path = `${PREFIX}/src/worktree-only.ts`;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
expect(res.json().success).toBe(true);
|
||||
expect(res.json().data.content).toBe('worktree');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user