Skip to content

Commit 2d480e5

Browse files
cameroncookeclaude
andcommitted
fix(xcode-ide): Bound IDE state fallback to workspace root
Limit parent-directory fallback discovery for xcuserstate to a configured search root instead of traversing to filesystem root. Pass the resolved workspace root from server bootstrap so Xcode IDE sync and watcher startup only search within the active workspace context. Add regression coverage for nested cwd discovery and boundary enforcement. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ff4d3f2 commit 2d480e5

File tree

5 files changed

+150
-23
lines changed

5 files changed

+150
-23
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
- Added daemon idle shutdown in CLI mode: per-workspace daemons now auto-exit after 10 minutes of inactivity when no active stateful sessions exist.
1414
- Inverted idle activity tracking to a generic daemon activity registry so long-running tools report lifecycle activity without hardcoded daemon imports.
1515

16+
### Fixed
17+
- Fix Xcode IDE state discovery fallback to check parent directories only up to the resolved workspace root boundary when started from a nested working directory without explicit `projectPath`/`workspacePath`.
18+
1619
## [2.0.0] - 2026-02-02
1720

1821
### Breaking

src/server/bootstrap.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getRegisteredWorkflows, registerWorkflowsFromManifest } from '../utils/
88
import { bootstrapRuntime } from '../runtime/bootstrap-runtime.ts';
99
import { getXcodeToolsBridgeManager } from '../integrations/xcode-tools-bridge/index.ts';
1010
import { getMcpBridgeAvailability } from '../integrations/xcode-tools-bridge/core.ts';
11+
import { resolveWorkspaceRoot } from '../daemon/socket-path.ts';
1112
import { detectXcodeRuntime } from '../utils/xcode-process.ts';
1213
import { readXcodeIdeState } from '../utils/xcode-state-reader.ts';
1314
import { sessionStore } from '../utils/session-store.ts';
@@ -60,6 +61,10 @@ export async function bootstrapServer(
6061
}
6162

6263
const enabledWorkflows = result.runtime.config.enabledWorkflows;
64+
const workspaceRoot = resolveWorkspaceRoot({
65+
cwd: result.runtime.cwd,
66+
projectConfigPath: result.configPath,
67+
});
6368
const mcpBridge = await getMcpBridgeAvailability();
6469
const xcodeToolsAvailable = mcpBridge.available;
6570
log('info', `🚀 Initializing server...`);
@@ -79,6 +84,7 @@ export async function bootstrapServer(
7984
const xcodeState = await readXcodeIdeState({
8085
executor,
8186
cwd: result.runtime.cwd,
87+
searchRoot: workspaceRoot,
8288
projectPath,
8389
workspacePath,
8490
});
@@ -125,6 +131,7 @@ export async function bootstrapServer(
125131
const watcherStarted = await startXcodeStateWatcher({
126132
executor,
127133
cwd: result.runtime.cwd,
134+
searchRoot: workspaceRoot,
128135
projectPath,
129136
workspacePath,
130137
});

src/utils/__tests__/xcode-state-reader.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,41 @@ describe('findXcodeStateFile', () => {
7878
expect(result).toBeUndefined();
7979
});
8080

81+
it('finds project in parent directory when cwd is nested within searchRoot', async () => {
82+
const executor = createCommandMatchingMockExecutor({
83+
whoami: { output: 'testuser\n' },
84+
'find /test/project/subdir -maxdepth 6': { output: '' },
85+
'find /test/project -maxdepth 1': { output: '/test/project/MyApp.xcodeproj\n' },
86+
stat: { output: '1704067200\n' },
87+
});
88+
89+
const result = await findXcodeStateFile({
90+
executor,
91+
cwd: '/test/project/subdir',
92+
searchRoot: '/test/project',
93+
});
94+
95+
expect(result).toBe(
96+
'/test/project/MyApp.xcodeproj/project.xcworkspace/xcuserdata/testuser.xcuserdatad/UserInterfaceState.xcuserstate',
97+
);
98+
});
99+
100+
it('does not search above searchRoot boundary', async () => {
101+
const executor = createCommandMatchingMockExecutor({
102+
whoami: { output: 'testuser\n' },
103+
'find /test/project/subdir -maxdepth 6': { output: '' },
104+
'find /test/project -maxdepth 1': { output: '' },
105+
});
106+
107+
const result = await findXcodeStateFile({
108+
executor,
109+
cwd: '/test/project/subdir',
110+
searchRoot: '/test/project',
111+
});
112+
113+
expect(result).toBeUndefined();
114+
});
115+
81116
it('uses configured workspacePath directly', async () => {
82117
const executor = createCommandMatchingMockExecutor({
83118
whoami: { output: 'testuser\n' },

src/utils/xcode-state-reader.ts

Lines changed: 103 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* running under Xcode's coding agent.
99
*/
1010

11+
import { dirname, resolve, sep } from 'node:path';
1112
import { log } from './logger.ts';
1213
import { parseXcuserstate } from './nskeyedarchiver-parser.ts';
1314
import type { CommandExecutor } from './execution/index.ts';
@@ -22,6 +23,8 @@ export interface XcodeStateResult {
2223
export interface XcodeStateReaderContext {
2324
executor: CommandExecutor;
2425
cwd: string;
26+
/** Optional boundary for parent-directory fallback search (typically workspace root) */
27+
searchRoot?: string;
2528
/** Optional pre-configured workspace path to use directly */
2629
workspacePath?: string;
2730
/** Optional pre-configured project path to use directly */
@@ -33,16 +36,79 @@ export interface XcodeStateReaderContext {
3336
*
3437
* Search order:
3538
* 1. Use configured workspacePath/projectPath if provided
36-
* 2. Search for .xcworkspace/.xcodeproj in cwd and parent directories
39+
* 2. Search for .xcworkspace/.xcodeproj under cwd
40+
* 3. If none (or to broaden candidates), search direct children of parent directories
41+
* up to searchRoot (workspace boundary)
3742
*
3843
* For each found project:
3944
* - .xcworkspace: <workspace>/xcuserdata/<user>.xcuserdatad/UserInterfaceState.xcuserstate
4045
* - .xcodeproj: <project>/project.xcworkspace/xcuserdata/<user>.xcuserdatad/UserInterfaceState.xcuserstate
4146
*/
47+
function buildFindProjectsCommand(root: string, maxDepth: number): string[] {
48+
return [
49+
'find',
50+
root,
51+
'-maxdepth',
52+
String(maxDepth),
53+
'(',
54+
'-name',
55+
'*.xcworkspace',
56+
'-o',
57+
'-name',
58+
'*.xcodeproj',
59+
')',
60+
'-type',
61+
'd',
62+
];
63+
}
64+
65+
function isPathWithinBoundary(path: string, boundary: string): boolean {
66+
return path === boundary || path.startsWith(`${boundary}${sep}`);
67+
}
68+
69+
function listParentDirectories(startPath: string, boundaryPath: string): string[] {
70+
const parents: string[] = [];
71+
const start = resolve(startPath);
72+
const boundary = resolve(boundaryPath);
73+
74+
if (!isPathWithinBoundary(start, boundary)) {
75+
return parents;
76+
}
77+
78+
let current = start;
79+
while (true) {
80+
const parent = dirname(current);
81+
if (parent === current) {
82+
break;
83+
}
84+
85+
if (!isPathWithinBoundary(parent, boundary)) {
86+
break;
87+
}
88+
89+
parents.push(parent);
90+
if (parent === boundary) {
91+
break;
92+
}
93+
94+
current = parent;
95+
}
96+
97+
return parents;
98+
}
99+
100+
function collectFindPaths(output: string): string[] {
101+
return output
102+
.trim()
103+
.split('\n')
104+
.map((path) => path.trim())
105+
.filter(Boolean);
106+
}
107+
42108
export async function findXcodeStateFile(
43109
ctx: XcodeStateReaderContext,
44110
): Promise<string | undefined> {
45-
const { executor, cwd, workspacePath, projectPath } = ctx;
111+
const { executor, cwd, searchRoot, workspacePath, projectPath } = ctx;
46112

47113
// Get current username
48114
const userResult = await executor(['whoami'], 'Get username', false);
@@ -68,33 +134,47 @@ export async function findXcodeStateFile(
68134
log('debug', `[xcode-state] Configured path xcuserstate not found: ${xcuserstatePath}`);
69135
}
70136

71-
// Search for projects with increased depth (projects can be nested deeper)
72-
const findResult = await executor(
73-
[
74-
'find',
75-
cwd,
76-
'-maxdepth',
77-
'6',
78-
'(',
79-
'-name',
80-
'*.xcworkspace',
81-
'-o',
82-
'-name',
83-
'*.xcodeproj',
84-
')',
85-
'-type',
86-
'd',
87-
],
88-
'Find Xcode project/workspace',
137+
const discoveredPaths = new Set<string>();
138+
139+
// Search descendants from cwd with increased depth (projects can be nested deeper).
140+
const descendantsResult = await executor(
141+
buildFindProjectsCommand(cwd, 6),
142+
'Find Xcode project/workspace in cwd descendants',
89143
false,
90144
);
145+
if (descendantsResult.success && descendantsResult.output.trim()) {
146+
for (const path of collectFindPaths(descendantsResult.output)) {
147+
discoveredPaths.add(path);
148+
}
149+
}
91150

92-
if (!findResult.success || !findResult.output.trim()) {
93-
log('debug', `[xcode-state] No Xcode project/workspace found in ${cwd}`);
151+
// Also search direct children of parent directories to support nested cwd usage.
152+
// Example: cwd=/repo/feature/subdir, project=/repo/App.xcodeproj
153+
// Parent traversal stops at searchRoot (workspace boundary).
154+
const parentSearchBoundary = searchRoot ?? cwd;
155+
for (const parentDir of listParentDirectories(cwd, parentSearchBoundary)) {
156+
const parentResult = await executor(
157+
buildFindProjectsCommand(parentDir, 1),
158+
'Find Xcode project/workspace in parent directory',
159+
false,
160+
);
161+
if (!parentResult.success || !parentResult.output.trim()) {
162+
continue;
163+
}
164+
for (const path of collectFindPaths(parentResult.output)) {
165+
discoveredPaths.add(path);
166+
}
167+
}
168+
169+
if (discoveredPaths.size === 0) {
170+
log(
171+
'debug',
172+
`[xcode-state] No Xcode project/workspace found in ${cwd} (boundary: ${parentSearchBoundary})`,
173+
);
94174
return undefined;
95175
}
96176

97-
const paths = findResult.output.trim().split('\n').filter(Boolean);
177+
const paths = [...discoveredPaths];
98178

99179
// Filter out nested workspaces inside xcodeproj and sort
100180
const filteredPaths = paths

src/utils/xcode-state-watcher.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ async function processFileChange(): Promise<void> {
179179
export interface StartWatcherOptions {
180180
executor?: CommandExecutor;
181181
cwd?: string;
182+
searchRoot?: string;
182183
workspacePath?: string;
183184
projectPath?: string;
184185
}
@@ -198,6 +199,7 @@ export async function startXcodeStateWatcher(options: StartWatcherOptions = {}):
198199
const xcuserstatePath = await findXcodeStateFile({
199200
executor,
200201
cwd,
202+
searchRoot: options.searchRoot,
201203
workspacePath: options.workspacePath,
202204
projectPath: options.projectPath,
203205
});

0 commit comments

Comments
 (0)