Skip to content

Commit b650587

Browse files
committed
feat: support nested child repos in pane worktrees
Projects can contain child repos under the root repo. This adds pane-level support for selecting those child repos and carrying them through the same worktree lifecycle as the root repo, so create, reopen, resume, bootstrap, and cleanup keep the pane's related repos together. Constraint: Preserve existing root-only behavior and keep child repo paths project-scoped and relative Rejected: Treating child repos as separate panes | one task should keep root and child repo changes in the same pane lifecycle Confidence: high Scope-risk: moderate Directive: Keep root and nested child repo lifecycle behavior aligned when changing pane creation, reopen/resume, bootstrap, or cleanup Tested: pnpm run typecheck; pnpm vitest run __tests__/linkedRepoConfig.test.ts __tests__/worktreeMetadata.test.ts __tests__/reopenWorktree.test.ts __tests__/resumeBranches.test.ts __tests__/worktreeCleanupService.test.ts __tests__/integration/paneLifecycle.test.ts; pnpm vitest run
1 parent 040bac5 commit b650587

23 files changed

Lines changed: 1412 additions & 97 deletions

__tests__/integration/paneLifecycle.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ describe('Pane Lifecycle Integration Tests', () => {
300300
agent: 'claude',
301301
projectName: 'test-project',
302302
existingPanes: [],
303+
slugBase: 'add-user',
303304
},
304305
['claude']
305306
);
@@ -318,6 +319,60 @@ describe('Pane Lifecycle Integration Tests', () => {
318319
)).toBe(false);
319320
});
320321

322+
it('passes configured linked child repos into the pane bootstrap config', async () => {
323+
fsMock.readFileSync.mockImplementation((target) => {
324+
const value = String(target);
325+
if (value.endsWith('/.dmux/settings.json')) {
326+
return JSON.stringify({ linkedRepoPaths: 'packages/docs\nservices/api' });
327+
}
328+
if (value.endsWith('/.dmux/dmux.config.json')) {
329+
return JSON.stringify({ controlPaneId: '%0' });
330+
}
331+
return JSON.stringify({});
332+
});
333+
334+
const { createPane } = await import('../../src/utils/paneCreation.js');
335+
336+
await createPane(
337+
{
338+
prompt: 'open linked workspace',
339+
agent: 'claude',
340+
projectName: 'test-project',
341+
existingPanes: [],
342+
slugBase: 'linked-workspace',
343+
},
344+
['claude']
345+
);
346+
347+
const bootstrapConfig = getLatestBootstrapConfig();
348+
expect(bootstrapConfig.metadata.linkedRepoPaths).toEqual([
349+
'packages/docs',
350+
'services/api',
351+
]);
352+
});
353+
354+
it('passes explicit linked child repo selections into the pane bootstrap config', async () => {
355+
const { createPane } = await import('../../src/utils/paneCreation.js');
356+
357+
await createPane(
358+
{
359+
prompt: 'open selected linked repos',
360+
agent: 'claude',
361+
projectName: 'test-project',
362+
existingPanes: [],
363+
slugBase: 'linked-selection',
364+
linkedRepoPaths: ['external/infinite-canvas-web', 'backend/docs-ui'],
365+
},
366+
['claude']
367+
);
368+
369+
const bootstrapConfig = getLatestBootstrapConfig();
370+
expect(bootstrapConfig.metadata.linkedRepoPaths).toEqual([
371+
'external/infinite-canvas-web',
372+
'backend/docs-ui',
373+
]);
374+
});
375+
321376
it('passes remote tracking baseBranch values to bootstrap without forcing refs/heads', async () => {
322377
fsMock.readFileSync.mockImplementation((target) => {
323378
const value = String(target);

__tests__/linkedRepoConfig.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import fs from 'fs';
2+
import os from 'os';
3+
import path from 'path';
4+
import { afterEach, describe, expect, it } from 'vitest';
5+
import {
6+
discoverLinkedRepoPaths,
7+
parseLinkedRepoPathsInput,
8+
resolveLinkedRepoReferences,
9+
validateLinkedRepoPathsSetting,
10+
} from '../src/utils/linkedRepoConfig.js';
11+
12+
const tempDirs: string[] = [];
13+
14+
afterEach(() => {
15+
while (tempDirs.length > 0) {
16+
const tempDir = tempDirs.pop();
17+
if (tempDir) {
18+
fs.rmSync(tempDir, { recursive: true, force: true });
19+
}
20+
}
21+
});
22+
23+
describe('linkedRepoConfig', () => {
24+
it('parses newline and comma separated repo paths into a deduped list', () => {
25+
expect(parseLinkedRepoPathsInput('packages/docs, services/api\npackages/docs')).toEqual([
26+
'packages/docs',
27+
'services/api',
28+
]);
29+
});
30+
31+
it('rejects linked repo paths that escape the project root', () => {
32+
expect(() => validateLinkedRepoPathsSetting('../outside')).toThrow(
33+
'cannot escape the project root'
34+
);
35+
});
36+
37+
it('resolves configured child repositories relative to the project root', () => {
38+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dmux-linked-repos-'));
39+
tempDirs.push(projectRoot);
40+
41+
const childRepo = path.join(projectRoot, 'packages', 'docs');
42+
fs.mkdirSync(path.join(childRepo, '.git'), { recursive: true });
43+
44+
expect(resolveLinkedRepoReferences(projectRoot, 'packages/docs')).toEqual([
45+
{
46+
relativePath: 'packages/docs',
47+
repoPath: childRepo,
48+
},
49+
]);
50+
});
51+
52+
it('discovers nested child repositories while skipping generated directories', () => {
53+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dmux-linked-repo-discovery-'));
54+
tempDirs.push(projectRoot);
55+
56+
const childRepo = path.join(projectRoot, 'external', 'infinite-canvas-web');
57+
const nestedRepo = path.join(projectRoot, 'tools', 'workspace-helper');
58+
const ignoredRepo = path.join(projectRoot, 'node_modules', 'ignored-package');
59+
60+
fs.mkdirSync(path.join(childRepo, '.git'), { recursive: true });
61+
fs.mkdirSync(path.join(nestedRepo, '.git'), { recursive: true });
62+
fs.mkdirSync(path.join(ignoredRepo, '.git'), { recursive: true });
63+
64+
expect(discoverLinkedRepoPaths(projectRoot)).toEqual([
65+
'external/infinite-canvas-web',
66+
'tools/workspace-helper',
67+
]);
68+
});
69+
});

__tests__/newPaneFieldNavigation.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,11 @@ describe('new pane field navigation', () => {
1616
expect(getPreviousNewPaneField('branchName')).toBe('baseBranch');
1717
expect(getPreviousNewPaneField('baseBranch')).toBe('prompt');
1818
});
19+
20+
it('includes linked repo selection in the cycle when child repos are available', () => {
21+
expect(getNextNewPaneField('branchName', { hasLinkedRepos: true })).toBe('linkedRepos');
22+
expect(getNextNewPaneField('linkedRepos', { hasLinkedRepos: true })).toBe('prompt');
23+
expect(getPreviousNewPaneField('prompt', { hasLinkedRepos: true })).toBe('linkedRepos');
24+
expect(getPreviousNewPaneField('linkedRepos', { hasLinkedRepos: true })).toBe('branchName');
25+
});
1926
});

__tests__/popupManager.newPanePopup.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,25 @@ describe('PopupManager launchNewPanePopup', () => {
126126
});
127127
});
128128

129+
it('normalizes linked child repo selections from popup payloads', async () => {
130+
const manager = createPopupManager({ promptForGitOptionsOnCreate: true }) as any;
131+
manager.checkPopupSupport = vi.fn(() => true);
132+
manager.launchPopup = vi.fn().mockResolvedValue({
133+
success: true,
134+
data: {
135+
prompt: 'prompt',
136+
linkedRepoPaths: [' external/web ', 'packages/docs', 'external/web'],
137+
},
138+
});
139+
140+
const result = await manager.launchNewPanePopup('/tmp/project');
141+
142+
expect(result).toEqual({
143+
prompt: 'prompt',
144+
linkedRepoPaths: ['external/web', 'packages/docs'],
145+
});
146+
});
147+
129148
it('returns null for malformed popup payloads', async () => {
130149
const manager = createPopupManager({ promptForGitOptionsOnCreate: true }) as any;
131150
manager.checkPopupSupport = vi.fn(() => true);

__tests__/reopenWorktree.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ const recalculateAndApplyLayoutMock = vi.hoisted(() => vi.fn(async () => {}));
2222
const getInstalledAgentsMock = vi.hoisted(() => vi.fn(async () => ['claude', 'codex']));
2323
const filterEnabledAgentsMock = vi.hoisted(() => vi.fn((agents: string[]) => agents));
2424
const destroyWelcomePaneCoordinatedMock = vi.hoisted(() => vi.fn());
25+
const ensureWorkspaceWorktreesMock = vi.hoisted(() => vi.fn(async () => []));
26+
const getConfiguredLinkedRepoPathsMock = vi.hoisted(() => vi.fn(() => []));
27+
const writeWorktreeMetadataMock = vi.hoisted(() => vi.fn());
2528
const readWorktreeMetadataMock = vi.hoisted(() => vi.fn(() => ({
2629
agent: 'codex',
2730
permissionMode: 'bypassPermissions',
@@ -74,6 +77,12 @@ vi.mock('../src/utils/agentDetection.js', () => ({
7477

7578
vi.mock('../src/utils/worktreeMetadata.js', () => ({
7679
readWorktreeMetadata: readWorktreeMetadataMock,
80+
writeWorktreeMetadata: writeWorktreeMetadataMock,
81+
}));
82+
83+
vi.mock('../src/utils/linkedWorktrees.js', () => ({
84+
ensureWorkspaceWorktrees: ensureWorkspaceWorktreesMock,
85+
getConfiguredLinkedRepoPaths: getConfiguredLinkedRepoPathsMock,
7786
}));
7887

7988
vi.mock('../src/utils/paneTitle.js', () => ({
@@ -105,6 +114,56 @@ describe('reopenWorktree', () => {
105114
permissionMode: 'bypassPermissions',
106115
branchName: 'feature/reopen-me',
107116
});
117+
ensureWorkspaceWorktreesMock.mockResolvedValue([]);
118+
getConfiguredLinkedRepoPathsMock.mockReturnValue([]);
119+
});
120+
121+
it('ensures linked child worktrees before resuming an existing pane', async () => {
122+
readWorktreeMetadataMock.mockReturnValue({
123+
agent: 'codex',
124+
permissionMode: 'bypassPermissions',
125+
branchName: 'feature/reopen-me',
126+
linkedRepoPaths: ['packages/docs'],
127+
});
128+
ensureWorkspaceWorktreesMock.mockResolvedValue([
129+
{
130+
isRoot: true,
131+
repoPath: '/repo',
132+
relativePath: '',
133+
worktreePath: '/repo/.dmux/worktrees/reopen-me',
134+
createdWorktree: false,
135+
},
136+
{
137+
isRoot: false,
138+
repoPath: '/repo/packages/docs',
139+
relativePath: 'packages/docs',
140+
worktreePath: '/repo/.dmux/worktrees/reopen-me/packages/docs',
141+
createdWorktree: true,
142+
},
143+
]);
144+
145+
const { reopenWorktree } = await import('../src/utils/reopenWorktree.js');
146+
147+
await reopenWorktree({
148+
slug: 'reopen-me',
149+
worktreePath: '/repo/.dmux/worktrees/reopen-me',
150+
projectRoot: '/repo',
151+
existingPanes: [],
152+
sessionProjectRoot: '/repo',
153+
sessionConfigPath: '/repo/.dmux/dmux.config.json',
154+
});
155+
156+
expect(ensureWorkspaceWorktreesMock).toHaveBeenCalledWith({
157+
projectRoot: '/repo',
158+
rootWorktreePath: '/repo/.dmux/worktrees/reopen-me',
159+
branchName: 'feature/reopen-me',
160+
linkedRepoPaths: ['packages/docs'],
161+
fallbackStartPointMode: 'current-head',
162+
});
163+
expect(writeWorktreeMetadataMock).toHaveBeenCalledWith(
164+
'/repo/.dmux/worktrees/reopen-me/packages/docs',
165+
{ branchName: 'feature/reopen-me' }
166+
);
108167
});
109168

110169
it('uses stored agent metadata and permission mode for resume', async () => {

__tests__/resumeBranches.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ vi.mock('../src/utils/settingsManager.js', () => ({
3030
SettingsManager: vi.fn(() => ({
3131
getSettings: vi.fn(() => ({
3232
permissionMode: 'plan',
33+
linkedRepoPaths: 'child-repo',
3334
})),
3435
})),
3536
}));
@@ -421,6 +422,7 @@ describe('resumeBranches', () => {
421422
expect.objectContaining({
422423
agent: 'codex',
423424
permissionMode: 'plan',
425+
linkedRepoPaths: ['child-repo'],
424426
branchName: 'feature/remote-shared',
425427
})
426428
);

__tests__/worktreeMetadata.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe('worktree metadata persistence', () => {
2828
permissionMode: 'bypassPermissions',
2929
displayName: 'Review Queue',
3030
branchName: 'feat/child-worktree',
31+
linkedRepoPaths: ['packages/design-system', 'services/api'],
3132
mergeTargetChain: [
3233
{
3334
displayName: 'Feature Parent',
@@ -48,6 +49,7 @@ describe('worktree metadata persistence', () => {
4849
permissionMode: 'bypassPermissions',
4950
displayName: 'Review Queue',
5051
branchName: 'feat/child-worktree',
52+
linkedRepoPaths: ['packages/design-system', 'services/api'],
5153
mergeTargetChain: [
5254
{
5355
displayName: 'Feature Parent',
@@ -63,4 +65,17 @@ describe('worktree metadata persistence', () => {
6365
],
6466
});
6567
});
68+
69+
it('preserves an explicit empty linked repo selection so reopen does not fall back to project defaults', () => {
70+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dmux-worktree-meta-empty-'));
71+
tempDirs.push(tempDir);
72+
73+
writeWorktreeMetadata(tempDir, {
74+
linkedRepoPaths: [],
75+
});
76+
77+
expect(readWorktreeMetadata(tempDir)).toEqual({
78+
linkedRepoPaths: [],
79+
});
80+
});
6681
});

src/components/popups/newPaneFieldNavigation.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,36 @@
44
* Specifically, the 'prompt' -> 'base branch' -> 'new branch name' cycle.
55
*/
66

7-
export type NewPaneField = 'prompt' | 'baseBranch' | 'branchName';
7+
export type NewPaneField = 'prompt' | 'baseBranch' | 'branchName' | 'linkedRepos';
88

9-
export function getNextNewPaneField(current: NewPaneField): NewPaneField {
10-
if (current === 'prompt') return 'baseBranch';
11-
if (current === 'baseBranch') return 'branchName';
12-
return 'prompt';
9+
function getFieldOrder(hasLinkedRepos: boolean): NewPaneField[] {
10+
return hasLinkedRepos
11+
? ['prompt', 'baseBranch', 'branchName', 'linkedRepos']
12+
: ['prompt', 'baseBranch', 'branchName'];
1313
}
1414

15-
export function getPreviousNewPaneField(current: NewPaneField): NewPaneField {
16-
if (current === 'prompt') return 'branchName';
17-
if (current === 'baseBranch') return 'prompt';
18-
return 'baseBranch';
15+
export function getNextNewPaneField(
16+
current: NewPaneField,
17+
options: { hasLinkedRepos?: boolean } = {}
18+
): NewPaneField {
19+
const order = getFieldOrder(options.hasLinkedRepos ?? false);
20+
const currentIndex = order.indexOf(current);
21+
if (currentIndex === -1) {
22+
return order[0];
23+
}
24+
25+
return order[(currentIndex + 1) % order.length];
26+
}
27+
28+
export function getPreviousNewPaneField(
29+
current: NewPaneField,
30+
options: { hasLinkedRepos?: boolean } = {}
31+
): NewPaneField {
32+
const order = getFieldOrder(options.hasLinkedRepos ?? false);
33+
const currentIndex = order.indexOf(current);
34+
if (currentIndex === -1) {
35+
return order[0];
36+
}
37+
38+
return order[(currentIndex - 1 + order.length) % order.length];
1939
}

0 commit comments

Comments
 (0)