Skip to content

Commit f9f1c9e

Browse files
committed
🤖 Invert architecture: Use workspace names as directories
Terminals always resolve symlinks, showing stable IDs instead of names. Instead of managing symlinks, use workspace names as real directory names and track stable IDs in config. Changes: - Workspaces created with name as directory (not stable ID) - Rename uses `git worktree move` to rename directory + update config - Removed all symlink creation/management code - Both stableWorkspacePath and namedWorkspacePath now return same path - Config stores ID mapping: workspace.id tracks stable identity Benefits: - Simpler code (no symlink management) - Better UX (terminals naturally show friendly names) - Still stable (IDs tracked in config, renames don't break identity) - No existing users (can make breaking changes) _Generated with `cmux`_
1 parent f2a3e35 commit f9f1c9e

File tree

6 files changed

+54
-218
lines changed

6 files changed

+54
-218
lines changed

src/config.test.ts

Lines changed: 0 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -35,104 +35,7 @@ describe("Config", () => {
3535
});
3636
});
3737

38-
describe("symlink management", () => {
39-
let projectPath: string;
40-
let projectDir: string;
41-
42-
beforeEach(() => {
43-
projectPath = "/fake/project";
44-
const projectName = "project";
45-
projectDir = path.join(config.srcDir, projectName);
46-
fs.mkdirSync(projectDir, { recursive: true });
47-
});
48-
49-
it("should create a symlink from name to ID", () => {
50-
const id = "abc123def4";
51-
const name = "feature-branch";
52-
const idPath = path.join(projectDir, id);
53-
const symlinkPath = path.join(projectDir, name);
54-
55-
// Create the actual ID directory
56-
fs.mkdirSync(idPath);
57-
58-
// Create symlink
59-
config.createWorkspaceSymlink(projectPath, id, name);
60-
61-
// Verify symlink exists and points to ID
62-
expect(fs.existsSync(symlinkPath)).toBe(true);
63-
const stats = fs.lstatSync(symlinkPath);
64-
expect(stats.isSymbolicLink()).toBe(true);
65-
expect(fs.readlinkSync(symlinkPath)).toBe(id);
66-
});
67-
68-
it("should update a symlink when renaming", () => {
69-
const id = "abc123def4";
70-
const oldName = "old-name";
71-
const newName = "new-name";
72-
const idPath = path.join(projectDir, id);
73-
const oldSymlinkPath = path.join(projectDir, oldName);
74-
const newSymlinkPath = path.join(projectDir, newName);
75-
76-
// Create the actual ID directory and initial symlink
77-
fs.mkdirSync(idPath);
78-
fs.symlinkSync(id, oldSymlinkPath, "dir");
79-
80-
// Update symlink
81-
config.updateWorkspaceSymlink(projectPath, oldName, newName, id);
82-
83-
// Verify old symlink removed and new one created
84-
expect(fs.existsSync(oldSymlinkPath)).toBe(false);
85-
expect(fs.existsSync(newSymlinkPath)).toBe(true);
86-
const stats = fs.lstatSync(newSymlinkPath);
87-
expect(stats.isSymbolicLink()).toBe(true);
88-
expect(fs.readlinkSync(newSymlinkPath)).toBe(id);
89-
});
90-
91-
it("should remove a symlink", () => {
92-
const id = "abc123def4";
93-
const name = "feature-branch";
94-
const idPath = path.join(projectDir, id);
95-
const symlinkPath = path.join(projectDir, name);
96-
97-
// Create the actual ID directory and symlink
98-
fs.mkdirSync(idPath);
99-
fs.symlinkSync(id, symlinkPath, "dir");
100-
101-
// Remove symlink
102-
config.removeWorkspaceSymlink(projectPath, name);
10338

104-
// Verify symlink removed but ID directory still exists
105-
expect(fs.existsSync(symlinkPath)).toBe(false);
106-
expect(fs.existsSync(idPath)).toBe(true);
107-
});
108-
109-
it("should handle removing non-existent symlink gracefully", () => {
110-
const name = "nonexistent";
111-
expect(() => {
112-
config.removeWorkspaceSymlink(projectPath, name);
113-
}).not.toThrow();
114-
});
115-
116-
it("should replace existing symlink when creating", () => {
117-
const id = "abc123def4";
118-
const name = "feature-branch";
119-
const idPath = path.join(projectDir, id);
120-
const symlinkPath = path.join(projectDir, name);
121-
122-
// Create the actual ID directory
123-
fs.mkdirSync(idPath);
124-
125-
// Create initial symlink to different target
126-
fs.symlinkSync("different-id", symlinkPath, "dir");
127-
128-
// Create new symlink (should replace old one)
129-
config.createWorkspaceSymlink(projectPath, id, name);
130-
131-
// Verify symlink now points to new ID
132-
expect(fs.existsSync(symlinkPath)).toBe(true);
133-
expect(fs.readlinkSync(symlinkPath)).toBe(id);
134-
});
135-
});
13639

13740
describe("getAllWorkspaceMetadata with migration", () => {
13841
it("should migrate legacy workspace without metadata file", () => {

src/config.ts

Lines changed: 9 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -133,28 +133,20 @@ export class Config {
133133
return path.join(this.srcDir, projectName, workspaceId);
134134
}
135135

136-
/**
137-
* Get the user-friendly symlink path (using workspace name).
138-
* This is the path users see and can navigate to.
139-
*/
140-
getWorkspaceSymlinkPath(projectPath: string, workspaceName: string): string {
141-
const projectName = this.getProjectName(projectPath);
142-
return path.join(this.srcDir, projectName, workspaceName);
143-
}
144-
145136
/**
146137
* Compute both workspace paths from metadata.
147-
* Returns an object with the stable ID path (for operations) and named path (for display).
138+
* Now both paths are the same (directory uses workspace name).
148139
*/
149140
getWorkspacePaths(metadata: WorkspaceMetadata): {
150-
/** Actual worktree path with stable ID (for terminal/operations) */
141+
/** Actual worktree path with name (for terminal/operations) */
151142
stableWorkspacePath: string;
152-
/** User-friendly symlink path with name (for display) */
143+
/** Same as stableWorkspacePath (no longer a symlink) */
153144
namedWorkspacePath: string;
154145
} {
146+
const path = this.getWorkspacePath(metadata.projectPath, metadata.name);
155147
return {
156-
stableWorkspacePath: this.getWorkspacePath(metadata.projectPath, metadata.id),
157-
namedWorkspacePath: this.getWorkspaceSymlinkPath(metadata.projectPath, metadata.name),
148+
stableWorkspacePath: path,
149+
namedWorkspacePath: path,
158150
};
159151
}
160152

@@ -167,79 +159,14 @@ export class Config {
167159
workspacePath: string,
168160
projectPath: string
169161
): FrontendWorkspaceMetadata {
170-
const stableWorkspacePath = workspacePath;
171-
const namedWorkspacePath = this.getWorkspaceSymlinkPath(projectPath, metadata.name);
172-
162+
// Both paths are the same now (directory uses workspace name)
173163
return {
174164
...metadata,
175-
stableWorkspacePath,
176-
namedWorkspacePath,
165+
stableWorkspacePath: workspacePath,
166+
namedWorkspacePath: workspacePath,
177167
};
178168
}
179169

180-
/**
181-
* Create a symlink from workspace name to workspace ID.
182-
* Example: ~/.cmux/src/cmux/feature-branch -> a1b2c3d4e5
183-
*/
184-
createWorkspaceSymlink(projectPath: string, id: string, name: string): void {
185-
const projectName = this.getProjectName(projectPath);
186-
const projectDir = path.join(this.srcDir, projectName);
187-
const symlinkPath = path.join(projectDir, name);
188-
const targetPath = id; // Relative symlink
189-
190-
try {
191-
// Remove existing symlink if it exists (use lstat to check if it's a symlink)
192-
try {
193-
const stats = fs.lstatSync(symlinkPath);
194-
if (stats.isSymbolicLink() || stats.isFile() || stats.isDirectory()) {
195-
fs.unlinkSync(symlinkPath);
196-
}
197-
} catch (e) {
198-
// Symlink doesn't exist, which is fine
199-
if (e && typeof e === "object" && "code" in e && e.code !== "ENOENT") {
200-
throw e;
201-
}
202-
}
203-
204-
// Create new symlink (relative path)
205-
fs.symlinkSync(targetPath, symlinkPath, "dir");
206-
} catch (error) {
207-
console.error(`Failed to create symlink ${symlinkPath} -> ${targetPath}:`, error);
208-
}
209-
}
210-
211-
/**
212-
* Update a workspace symlink when renaming.
213-
* Removes old symlink and creates new one.
214-
*/
215-
updateWorkspaceSymlink(projectPath: string, oldName: string, newName: string, id: string): void {
216-
// Remove old symlink, then create new one (createWorkspaceSymlink handles replacement)
217-
this.removeWorkspaceSymlink(projectPath, oldName);
218-
this.createWorkspaceSymlink(projectPath, id, newName);
219-
}
220-
221-
/**
222-
* Remove a workspace symlink.
223-
*/
224-
removeWorkspaceSymlink(projectPath: string, name: string): void {
225-
const projectName = this.getProjectName(projectPath);
226-
const symlinkPath = path.join(this.srcDir, projectName, name);
227-
228-
try {
229-
// Use lstat to avoid following the symlink
230-
const stats = fs.lstatSync(symlinkPath);
231-
if (stats.isSymbolicLink()) {
232-
fs.unlinkSync(symlinkPath);
233-
}
234-
} catch (error) {
235-
// ENOENT is expected if symlink doesn't exist
236-
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
237-
return; // Silently succeed if symlink doesn't exist
238-
}
239-
console.error(`Failed to remove symlink ${symlinkPath}:`, error);
240-
}
241-
}
242-
243170
/**
244171
* Find a workspace path and project path by workspace ID
245172
* @returns Object with workspace and project paths, or null if not found

src/git.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export interface WorktreeResult {
1111

1212
export interface CreateWorktreeOptions {
1313
trunkBranch: string;
14-
/** Workspace ID to use for directory name (if not provided, uses branchName) */
14+
/** Directory name to use for the worktree (if not provided, uses branchName) */
1515
workspaceId?: string;
1616
}
1717

src/services/aiService.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -523,9 +523,8 @@ export class AIService extends EventEmitter {
523523
return Err({ type: "unknown", raw: `Workspace ${workspaceId} not found in config` });
524524
}
525525

526-
// Use named workspace path (symlink) for user-facing operations
527-
// Agent commands should run in the path users see in the UI
528-
const workspacePath = this.config.getWorkspaceSymlinkPath(metadata.projectPath, metadata.name);
526+
// Get workspace path (directory name uses workspace name)
527+
const workspacePath = this.config.getWorkspacePath(metadata.projectPath, metadata.name);
529528

530529
// Build system message from workspace metadata
531530
const systemMessage = await buildSystemMessage(

src/services/ipcMain.ts

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -191,13 +191,13 @@ export class IpcMain {
191191

192192
const normalizedTrunkBranch = trunkBranch.trim();
193193

194-
// Generate stable workspace ID
194+
// Generate stable workspace ID (stored in config, not used for directory name)
195195
const workspaceId = this.config.generateStableId();
196196

197-
// Create the git worktree with the stable ID as directory name
197+
// Create the git worktree with the workspace name as directory name
198198
const result = await createWorktree(this.config, projectPath, branchName, {
199199
trunkBranch: normalizedTrunkBranch,
200-
workspaceId, // Use stable ID for directory name
200+
workspaceId: branchName, // Use name for directory (workspaceId param is misnamed, it's directoryName)
201201
});
202202

203203
if (result.success && result.path) {
@@ -234,8 +234,7 @@ export class IpcMain {
234234
return config;
235235
});
236236

237-
// Create symlink from name to ID for convenience
238-
this.config.createWorkspaceSymlink(projectPath, workspaceId, branchName);
237+
// No longer creating symlinks - directory name IS the workspace name
239238

240239
// Get complete metadata from config (includes paths)
241240
const allMetadata = this.config.getAllWorkspaceMetadata();
@@ -303,22 +302,37 @@ export class IpcMain {
303302
if (!workspace) {
304303
return Err("Failed to find workspace in config");
305304
}
306-
const { projectPath } = workspace;
305+
const { projectPath, workspacePath } = workspace;
307306

308-
// Update symlink
309-
this.config.updateWorkspaceSymlink(projectPath, oldName, newName, workspaceId);
310-
311-
// Note: metadata.json no longer written - config is the only source of truth
307+
// Compute new path (based on name)
308+
const projectName =
309+
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
310+
const oldPath = workspacePath;
311+
const newPath = this.config.getWorkspacePath(projectPath, newName);
312+
313+
// Use git worktree move to rename the worktree directory
314+
// This updates git's internal worktree metadata correctly
315+
try {
316+
const result = spawnSync("git", ["worktree", "move", oldPath, newPath], {
317+
cwd: projectPath,
318+
});
319+
if (result.status !== 0) {
320+
const stderr = result.stderr?.toString() || "Unknown error";
321+
return Err(`Failed to move worktree: ${stderr}`);
322+
}
323+
} catch (error) {
324+
const message = error instanceof Error ? error.message : String(error);
325+
return Err(`Failed to move worktree: ${message}`);
326+
}
312327

313-
// Update config with new name
328+
// Update config with new name and path
314329
this.config.editConfig((config) => {
315330
const projectConfig = config.projects.get(projectPath);
316331
if (projectConfig) {
317-
const workspaceEntry = projectConfig.workspaces.find(
318-
(w) => w.path === workspace.workspacePath
319-
);
332+
const workspaceEntry = projectConfig.workspaces.find((w) => w.path === oldPath);
320333
if (workspaceEntry) {
321334
workspaceEntry.name = newName;
335+
workspaceEntry.path = newPath; // Update path to reflect new directory name
322336
}
323337
}
324338
return config;
@@ -574,9 +588,8 @@ export class IpcMain {
574588
return Err(`Workspace ${workspaceId} not found in config`);
575589
}
576590

577-
// Use named workspace path (symlink) for user-facing operations
578-
// Users see the friendly name in the UI, so commands should run in that path
579-
const namedPath = this.config.getWorkspaceSymlinkPath(metadata.projectPath, metadata.name);
591+
// Get workspace path (directory name uses workspace name)
592+
const namedPath = this.config.getWorkspacePath(metadata.projectPath, metadata.name);
580593

581594
// Load project secrets
582595
const projectSecrets = this.config.getProjectSecrets(metadata.projectPath);
@@ -787,10 +800,7 @@ export class IpcMain {
787800
return { success: false, error: aiResult.error };
788801
}
789802

790-
// Remove symlink if we know the project path
791-
if (foundProjectPath) {
792-
this.config.removeWorkspaceSymlink(foundProjectPath, metadataResult.data.name);
793-
}
803+
// No longer need to remove symlinks (directory IS the workspace name)
794804

795805
// Update config to remove the workspace from all projects
796806
// We iterate through all projects instead of relying on foundProjectPath
@@ -802,10 +812,6 @@ export class IpcMain {
802812
projectConfig.workspaces = projectConfig.workspaces.filter((w) => w.path !== workspacePath);
803813
if (projectConfig.workspaces.length < initialCount) {
804814
configUpdated = true;
805-
// If we didn't have foundProjectPath earlier, try removing symlink now
806-
if (!foundProjectPath) {
807-
this.config.removeWorkspaceSymlink(projectPath, metadataResult.data.name);
808-
}
809815
}
810816
}
811817
if (configUpdated) {

0 commit comments

Comments
 (0)