Merge pull request #667: fix(mux): give a discovered tmux session its pane's directory, not the server cwd

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-22 19:23:09 +02:00
commit 620e7728ec
3 files changed
+169 -7

No files matched your search

+117 -1
View File
@@ -52,11 +52,24 @@ vi.mock('node:child_process', async () => {
};
});
// Paths realpathSync should resolve differently than this host does, e.g. a
// macOS `/tmp` -> `/private/tmp`. Hoisted so it survives vi.resetModules().
const realpathOverrides = vi.hoisted(() => new Map<string, string>());
// Mock fs to avoid file I/O
vi.mock('node:fs', async () => {
const actual = await vi.importActual('node:fs');
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
const realpathSync = Object.assign(
vi.fn((path: string, ...rest: unknown[]) =>
realpathOverrides.has(String(path))
? realpathOverrides.get(String(path))
: (actual.realpathSync as (...args: unknown[]) => string)(path, ...rest)
),
{ native: actual.realpathSync.native }
);
return {
...actual,
realpathSync,
existsSync: vi.fn().mockReturnValue(false),
readFileSync: vi.fn(),
mkdirSync: vi.fn(),
@@ -903,6 +916,92 @@ describe('TmuxManager (unit)', () => {
nonTestManager.destroy();
}
});
it('gives a discovered session the directory tmux reports for its pane, not the server cwd', async () => {
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes(' list-panes ')) {
return [
'codeman-aaaa1111|101|0|||/home/user/project-a',
// A hand-split session answers with the pane tmux lists first.
'codeman-bbbb2222|102|0|||/home/user/project-b',
'codeman-bbbb2222|103|0|||/home/user/project-b/sub',
// Set by whoever owns the pane, so it must pass the same check as any workingDir.
'codeman-cccc3333|104|0|||/home/user/$(touch pwned)',
// A tmux that reported no path.
'codeman-dddd4444|105|0|||',
// The launch directory every pane starts in; a remote or docker pane never leaves it.
'codeman-eeee5555|106|0|||/tmp',
// A directory deleted while its pane ran, as tmux 3.4 on Linux reports it. isValidPath()
// rejects it for the parentheses; a relaxed path pattern must not adopt a dead directory.
'codeman-abcd7777|108|0|||/home/user/gone (deleted)',
// Last row, path ending in a space: the output must not be trimmed.
'codeman-ffff6666|107|0|||/home/user/trailing ',
].join('\n');
}
return '';
});
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
const { discovered } = await nonTestManager.reconcileSessions();
expect(discovered).toEqual([
'restored-aaaa1111',
'restored-bbbb2222',
'restored-cccc3333',
'restored-dddd4444',
'restored-eeee5555',
'restored-abcd7777',
'restored-ffff6666',
]);
const dirs = Object.fromEntries(nonTestManager.getSessions().map((s) => [s.muxName, s.workingDir]));
expect(dirs).toEqual({
'codeman-aaaa1111': '/home/user/project-a',
'codeman-bbbb2222': '/home/user/project-b',
'codeman-cccc3333': process.cwd(),
'codeman-dddd4444': process.cwd(),
'codeman-eeee5555': process.cwd(),
'codeman-abcd7777': process.cwd(),
'codeman-ffff6666': '/home/user/trailing ',
});
// The directory comes from the same list-panes call, as its last field.
const listCalls = mockedExecSync.mock.calls
.map(([cmd]) => cmd)
.filter((cmd): cmd is string => typeof cmd === 'string' && cmd.includes(' list-panes '));
expect(listCalls).toHaveLength(1);
expect(listCalls[0]).toContain("|#{pane_current_path}'");
} finally {
nonTestManager.destroy();
}
});
it('treats the resolved spelling of the launch directory as no answer too (macOS /private/tmp)', async () => {
// On macOS /tmp is a symlink and a pane reports its process cwd resolved.
realpathOverrides.set('/tmp', '/private/tmp');
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes(' list-panes ')) {
return ['codeman-aaaa1111|101|0|||/private/tmp', 'codeman-bbbb2222|102|0|||/tmp'].join('\n');
}
return '';
});
try {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.reconcileSessions();
const dirs = Object.fromEntries(nonTestManager.getSessions().map((s) => [s.muxName, s.workingDir]));
expect(dirs).toEqual({
'codeman-aaaa1111': process.cwd(),
'codeman-bbbb2222': process.cwd(),
});
} finally {
nonTestManager.destroy();
}
} finally {
realpathOverrides.clear();
}
});
});
});
@@ -1013,6 +1112,23 @@ describe('parsePaneRows', () => {
expect(row.dead).toBeUndefined();
});
it('reads the pane directory from the last field', () => {
const [row] = parsePaneRows('codeman-aaaa|1234|0|||/home/user/project');
expect(row.pid).toBe(1234);
expect(row.dead).toBe(false);
expect(row.currentPath).toBe('/home/user/project');
});
it('keeps a pane directory that contains the separator whole', () => {
const [row] = parsePaneRows('codeman-aaaa|1234|0|||/home/user/a|b');
expect(row.currentPath).toBe('/home/user/a|b');
});
it('leaves the pane directory undefined when tmux reported none', () => {
expect(parsePaneRows('codeman-aaaa|1234')[0].currentPath).toBeUndefined();
expect(parsePaneRows('codeman-aaaa|1234|0|||')[0].currentPath).toBeUndefined();
});
it('returns one row per pane of a split session, in tmux order', () => {
const rows = parsePaneRows('codeman-aaaa|100|0|||\ncodeman-aaaa|200|1|0|');
expect(rows.map((row) => row.pid)).toEqual([100, 200]);