Skip to content

Commit c4c0185

Browse files
committed
🤖 Centralize workspace metadata in config.json
Move workspace metadata from scattered metadata.json files to centralized config.json. This establishes config as the single source of truth for workspace data and fixes missing projectPath errors in git status checks. **New config structure:** - Workspace entries now include: id, name, createdAt (optional) - Legacy path-only entries still supported (backward compat) - Automatic migration on app startup: reads metadata.json, writes to config **Benefits:** - Single source of truth (no scattered session files) - No more missing projectPath errors - Simpler architecture - Backward compatible **Changes:** - Workspace interface: Added optional id/name/createdAt fields - getAllWorkspaceMetadata(): Prefers config, falls back to metadata files - Workspace create/rename: Now writes full metadata to config - Migration: Automatic on first load, writes back to config **Migration strategy:** - Config entries with id/name: Used directly (new format) - Config entries with path only: Read from metadata.json, migrate to config - No metadata file: Generate legacy ID, save to config - Config saved once if any migrations occurred Metadata.json files kept for backward compat with older cmux versions. Fixes #259 (git status not appearing due to missing projectPath)
1 parent 7517660 commit c4c0185

File tree

3 files changed

+135
-54
lines changed

3 files changed

+135
-54
lines changed

src/config.test.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -161,17 +161,14 @@ describe("Config", () => {
161161
expect(metadata.projectName).toBe("project");
162162
expect(metadata.projectPath).toBe(projectPath);
163163

164-
// Verify metadata file was created
165-
const sessionDir = config.getSessionDir("project-feature-branch");
166-
const metadataPath = path.join(sessionDir, "metadata.json");
167-
expect(fs.existsSync(metadataPath)).toBe(true);
168-
169-
const savedMetadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8")) as {
170-
id: string;
171-
name: string;
172-
};
173-
expect(savedMetadata.id).toBe("project-feature-branch");
174-
expect(savedMetadata.name).toBe("feature-branch");
164+
// Verify metadata was migrated to config
165+
const configData = config.loadConfigOrDefault();
166+
const projectConfig = configData.projects.get(projectPath);
167+
expect(projectConfig).toBeDefined();
168+
expect(projectConfig!.workspaces).toHaveLength(1);
169+
const workspace = projectConfig!.workspaces[0];
170+
expect(workspace.id).toBe("project-feature-branch");
171+
expect(workspace.name).toBe("feature-branch");
175172
});
176173

177174
it("should use existing metadata file if present", () => {
@@ -204,14 +201,24 @@ describe("Config", () => {
204201
return cfg;
205202
});
206203

207-
// Get all metadata (should use existing metadata)
204+
// Get all metadata (should use existing metadata and migrate to config)
208205
const allMetadata = config.getAllWorkspaceMetadata();
209206

210207
expect(allMetadata).toHaveLength(1);
211208
const metadata = allMetadata[0];
212209
expect(metadata.id).toBe(workspaceId);
213210
expect(metadata.name).toBe("my-feature");
214211
expect(metadata.createdAt).toBe("2025-01-01T00:00:00.000Z");
212+
213+
// Verify metadata was migrated to config
214+
const configData = config.loadConfigOrDefault();
215+
const projectConfig = configData.projects.get(projectPath);
216+
expect(projectConfig).toBeDefined();
217+
expect(projectConfig!.workspaces).toHaveLength(1);
218+
const workspace = projectConfig!.workspaces[0];
219+
expect(workspace.id).toBe(workspaceId);
220+
expect(workspace.name).toBe("my-feature");
221+
expect(workspace.createdAt).toBe("2025-01-01T00:00:00.000Z");
215222
});
216223
});
217224
});

src/config.ts

Lines changed: 97 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,36 @@ import writeFileAtomic from "write-file-atomic";
77
import type { WorkspaceMetadata } from "./types/workspace";
88
import type { Secret, SecretsConfig } from "./types/secrets";
99

10+
/**
11+
* Workspace configuration in config.json.
12+
*
13+
* NEW FORMAT (preferred, used for all new workspaces):
14+
* {
15+
* "path": "~/.cmux/src/project/workspace-id", // Kept for backward compat
16+
* "id": "a1b2c3d4e5", // Stable workspace ID
17+
* "name": "feature-branch", // User-facing name
18+
* "createdAt": "2024-01-01T00:00:00Z" // Creation timestamp
19+
* }
20+
*
21+
* LEGACY FORMAT (old workspaces, still supported):
22+
* {
23+
* "path": "~/.cmux/src/project/workspace-id" // Only field present
24+
* }
25+
*
26+
* For legacy entries, metadata is read from ~/.cmux/sessions/{workspaceId}/metadata.json
27+
*/
1028
export interface Workspace {
11-
path: string; // Absolute path to workspace worktree (format: ~/.cmux/src/{projectName}/{workspaceId})
12-
// NOTE: The workspace ID is the basename of this path (stable random ID like 'a1b2c3d4e5').
13-
// Use config.getWorkspacePath(projectPath, workspaceId) to construct paths consistently.
29+
/** Absolute path to workspace worktree - REQUIRED for backward compatibility */
30+
path: string;
31+
32+
/** Stable workspace ID (10 hex chars for new workspaces) - optional for legacy */
33+
id?: string;
34+
35+
/** User-facing workspace name - optional for legacy */
36+
name?: string;
37+
38+
/** ISO 8601 creation timestamp - optional for legacy */
39+
createdAt?: string;
1440
}
1541

1642
export interface ProjectConfig {
@@ -292,20 +318,19 @@ export class Config {
292318

293319
/**
294320
* Get all workspace metadata by loading config and metadata files.
295-
* Performs eager migration for legacy workspaces on startup.
296-
*
297-
* Migration strategy:
298-
* - For each workspace in config, try to load metadata.json from session dir
299-
* - If metadata exists, use it (already migrated or new workspace)
300-
* - If metadata doesn't exist, this is a legacy workspace:
301-
* - Generate legacy ID from path (for backward compatibility)
302-
* - Extract name from workspace path
303-
* - Create and save metadata.json
304-
* - Create symlink if name differs from ID (new workspaces only)
321+
*
322+
* NEW BEHAVIOR: Config is the primary source of truth
323+
* - If workspace has id/name/createdAt in config, use those directly
324+
* - If workspace only has path, fall back to reading metadata.json
325+
* - Migrate old workspaces by copying metadata from files to config
326+
*
327+
* This centralizes workspace metadata in config.json and eliminates the need
328+
* for scattered metadata.json files (kept for backward compat with older versions).
305329
*/
306330
getAllWorkspaceMetadata(): WorkspaceMetadata[] {
307331
const config = this.loadConfigOrDefault();
308332
const workspaceMetadata: WorkspaceMetadata[] = [];
333+
let configModified = false;
309334

310335
for (const [projectPath, projectConfig] of config.projects) {
311336
const projectName = this.getProjectName(projectPath);
@@ -316,68 +341,95 @@ export class Config {
316341
workspace.path.split("/").pop() ?? workspace.path.split("\\").pop() ?? "unknown";
317342

318343
try {
319-
// Try to load metadata using workspace basename as ID (works for new workspaces with stable IDs)
344+
// NEW FORMAT: If workspace has metadata in config, use it directly
345+
if (workspace.id && workspace.name) {
346+
const metadata: WorkspaceMetadata = {
347+
id: workspace.id,
348+
name: workspace.name,
349+
projectName,
350+
projectPath,
351+
createdAt: workspace.createdAt,
352+
};
353+
workspaceMetadata.push(metadata);
354+
continue; // Skip metadata file lookup
355+
}
356+
357+
// LEGACY FORMAT: Fall back to reading metadata.json
358+
// Try workspace basename first (for new stable ID workspaces)
320359
let metadataPath = path.join(this.getSessionDir(workspaceBasename), "metadata.json");
360+
let metadataFound = false;
321361

322362
if (fs.existsSync(metadataPath)) {
323363
const data = fs.readFileSync(metadataPath, "utf-8");
324364
let metadata = JSON.parse(data) as WorkspaceMetadata;
325365

326-
// Migrate legacy metadata that's missing required fields
366+
// Ensure required fields are present
327367
if (!metadata.name || !metadata.projectPath) {
328368
metadata = {
329369
...metadata,
330370
name: metadata.name ?? workspaceBasename,
331371
projectPath: metadata.projectPath ?? projectPath,
332372
projectName: metadata.projectName ?? projectName,
333373
};
334-
// Save migrated metadata
335-
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
336374
}
337375

376+
// Migrate to config for next load
377+
workspace.id = metadata.id;
378+
workspace.name = metadata.name;
379+
workspace.createdAt = metadata.createdAt;
380+
configModified = true;
381+
338382
workspaceMetadata.push(metadata);
339-
} else {
340-
// Try legacy ID format (project-workspace)
383+
metadataFound = true;
384+
}
385+
386+
// Try legacy ID format (project-workspace)
387+
if (!metadataFound) {
341388
const legacyId = this.generateWorkspaceId(projectPath, workspace.path);
342389
metadataPath = path.join(this.getSessionDir(legacyId), "metadata.json");
343390

344391
if (fs.existsSync(metadataPath)) {
345392
const data = fs.readFileSync(metadataPath, "utf-8");
346393
let metadata = JSON.parse(data) as WorkspaceMetadata;
347394

348-
// Migrate legacy metadata that's missing required fields
395+
// Ensure required fields are present
349396
if (!metadata.name || !metadata.projectPath) {
350397
metadata = {
351398
...metadata,
352399
name: metadata.name ?? workspaceBasename,
353400
projectPath: metadata.projectPath ?? projectPath,
354401
projectName: metadata.projectName ?? projectName,
355402
};
356-
// Save migrated metadata
357-
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
358403
}
359404

360-
workspaceMetadata.push(metadata);
361-
} else {
362-
// No metadata found - create it for legacy workspace
363-
const metadata: WorkspaceMetadata = {
364-
id: legacyId, // Use legacy ID format for backward compatibility
365-
name: workspaceBasename,
366-
projectName,
367-
projectPath, // Add full project path
368-
// No createdAt for legacy workspaces (unknown)
369-
};
370-
371-
// Save metadata for future loads
372-
const sessionDir = this.getSessionDir(legacyId);
373-
if (!fs.existsSync(sessionDir)) {
374-
fs.mkdirSync(sessionDir, { recursive: true });
375-
}
376-
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
405+
// Migrate to config for next load
406+
workspace.id = metadata.id;
407+
workspace.name = metadata.name;
408+
workspace.createdAt = metadata.createdAt;
409+
configModified = true;
377410

378411
workspaceMetadata.push(metadata);
412+
metadataFound = true;
379413
}
380414
}
415+
416+
// No metadata found anywhere - create basic metadata
417+
if (!metadataFound) {
418+
const legacyId = this.generateWorkspaceId(projectPath, workspace.path);
419+
const metadata: WorkspaceMetadata = {
420+
id: legacyId,
421+
name: workspaceBasename,
422+
projectName,
423+
projectPath,
424+
};
425+
426+
// Save to config for next load
427+
workspace.id = metadata.id;
428+
workspace.name = metadata.name;
429+
configModified = true;
430+
431+
workspaceMetadata.push(metadata);
432+
}
381433
} catch (error) {
382434
console.error(`Failed to load/migrate workspace metadata:`, error);
383435
// Fallback to basic metadata if migration fails
@@ -386,12 +438,17 @@ export class Config {
386438
id: legacyId,
387439
name: workspaceBasename,
388440
projectName,
389-
projectPath, // Add full project path
441+
projectPath,
390442
});
391443
}
392444
}
393445
}
394446

447+
// Save config if we migrated any workspaces
448+
if (configModified) {
449+
this.saveConfig(config);
450+
}
451+
395452
return workspaceMetadata;
396453
}
397454

src/services/ipcMain.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ export class IpcMain {
224224
};
225225
await this.aiService.saveWorkspaceMetadata(workspaceId, metadata);
226226

227-
// Update config to include the new workspace
227+
// Update config to include the new workspace (with full metadata)
228228
this.config.editConfig((config) => {
229229
let projectConfig = config.projects.get(projectPath);
230230
if (!projectConfig) {
@@ -235,9 +235,12 @@ export class IpcMain {
235235
};
236236
config.projects.set(projectPath, projectConfig);
237237
}
238-
// Add workspace to project config
238+
// Add workspace to project config with full metadata
239239
projectConfig.workspaces.push({
240240
path: result.path!,
241+
id: workspaceId,
242+
name: branchName,
243+
createdAt: metadata.createdAt,
241244
});
242245
return config;
243246
});
@@ -322,6 +325,20 @@ export class IpcMain {
322325
return Err(`Failed to save metadata: ${saveResult.error}`);
323326
}
324327

328+
// Update config with new name
329+
this.config.editConfig((config) => {
330+
const projectConfig = config.projects.get(projectPath);
331+
if (projectConfig) {
332+
const workspaceEntry = projectConfig.workspaces.find(
333+
(w) => w.path === workspace.workspacePath
334+
);
335+
if (workspaceEntry) {
336+
workspaceEntry.name = newName;
337+
}
338+
}
339+
return config;
340+
});
341+
325342
// Emit metadata event with updated metadata (same workspace ID)
326343
const session = this.sessions.get(workspaceId);
327344
if (session) {

0 commit comments

Comments
 (0)