mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
Claude Code's EnterWorktree moves the running process into <repo>/.claude/worktrees/<name>, while Session.workingDir stays on the main checkout. The Files panel kept walking workingDir, so it listed a tree the agent had left. paneCwd already follows the move, measured on Claude Code 2.1.318 in both directions, so nothing new has to track the agent. It also follows every cd in a shell, so a new SessionState.liveWorktree narrows it to a linked worktree, read from the .git file without starting git. Only a folder strictly below workingDir is ever an answer, and the listing only starts there: returned paths stay relative to workingDir, so the preview, raw and edit routes are unchanged and confinement does not widen. A worktree outside the session folder stays unfollowed. The panel asks with root=live, shows a strip naming the worktree with a switch to the session folder, and follows the agent in and out while open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
5.5 KiB
TypeScript
126 lines
5.5 KiB
TypeScript
/**
|
|
* @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'),
|
|
});
|
|
});
|
|
});
|