Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 22 additions & 19 deletions src/App.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ function setupMockAPI(options: {
Promise.resolve({
success: true,
metadata: {
id: `${projectPath.split("/").pop() ?? "project"}-${branchName}`,
// Mock stable ID (production uses crypto.randomBytes(5).toString('hex'))
id: Math.random().toString(36).substring(2, 12),
name: branchName,
projectPath,
projectName: projectPath.split("/").pop() ?? "project",
Expand Down Expand Up @@ -135,15 +136,15 @@ export const SingleProject: Story = {
"/home/user/projects/my-app",
{
workspaces: [
{ path: "/home/user/.cmux/src/my-app/main", id: "my-app-main", name: "main" },
{ path: "/home/user/.cmux/src/my-app/main", id: "a1b2c3d4e5", name: "main" },
{
path: "/home/user/.cmux/src/my-app/feature-auth",
id: "my-app-feature-auth",
id: "f6g7h8i9j0",
name: "feature/auth",
},
{
path: "/home/user/.cmux/src/my-app/bugfix",
id: "my-app-bugfix",
id: "k1l2m3n4o5",
name: "bugfix/memory-leak",
},
],
Expand All @@ -153,14 +154,14 @@ export const SingleProject: Story = {

const workspaces: FrontendWorkspaceMetadata[] = [
{
id: "my-app-main",
id: "a1b2c3d4e5",
name: "main",
projectPath: "/home/user/projects/my-app",
projectName: "my-app",
namedWorkspacePath: "/home/user/.cmux/src/my-app/main",
},
{
id: "my-app-feature-auth",
id: "f6g7h8i9j0",
name: "feature/auth",
projectPath: "/home/user/projects/my-app",
projectName: "my-app",
Expand All @@ -181,15 +182,16 @@ export const SingleProject: Story = {

export const MultipleProjects: Story = {
render: () => {
// Note: Workspace IDs are fixtures using hex-like format (production uses random 10-hex chars)
const projects = new Map<string, ProjectConfig>([
[
"/home/user/projects/frontend",
{
workspaces: [
{ path: "/home/user/.cmux/src/frontend/main", id: "frontend-main", name: "main" },
{ path: "/home/user/.cmux/src/frontend/main", id: "1a2b3c4d5e", name: "main" },
{
path: "/home/user/.cmux/src/frontend/redesign",
id: "frontend-redesign",
id: "2b3c4d5e6f",
name: "redesign",
},
],
Expand All @@ -199,11 +201,11 @@ export const MultipleProjects: Story = {
"/home/user/projects/backend",
{
workspaces: [
{ path: "/home/user/.cmux/src/backend/main", id: "backend-main", name: "main" },
{ path: "/home/user/.cmux/src/backend/api-v2", id: "backend-api-v2", name: "api-v2" },
{ path: "/home/user/.cmux/src/backend/main", id: "3c4d5e6f7a", name: "main" },
{ path: "/home/user/.cmux/src/backend/api-v2", id: "4d5e6f7a8b", name: "api-v2" },
{
path: "/home/user/.cmux/src/backend/db-migration",
id: "backend-db-migration",
id: "5e6f7a8b9c",
name: "db-migration",
},
],
Expand All @@ -213,50 +215,50 @@ export const MultipleProjects: Story = {
"/home/user/projects/mobile",
{
workspaces: [
{ path: "/home/user/.cmux/src/mobile/main", id: "mobile-main", name: "main" },
{ path: "/home/user/.cmux/src/mobile/main", id: "6f7a8b9c0d", name: "main" },
],
},
],
]);

const workspaces: FrontendWorkspaceMetadata[] = [
{
id: "frontend-main",
id: "1a2b3c4d5e",
name: "main",
projectPath: "/home/user/projects/frontend",
projectName: "frontend",
namedWorkspacePath: "/home/user/.cmux/src/frontend/main",
},
{
id: "frontend-redesign",
id: "2b3c4d5e6f",
name: "redesign",
projectPath: "/home/user/projects/frontend",
projectName: "frontend",
namedWorkspacePath: "/home/user/.cmux/src/frontend/redesign",
},
{
id: "backend-main",
id: "3c4d5e6f7a",
name: "main",
projectPath: "/home/user/projects/backend",
projectName: "backend",
namedWorkspacePath: "/home/user/.cmux/src/backend/main",
},
{
id: "backend-api-v2",
id: "4d5e6f7a8b",
name: "api-v2",
projectPath: "/home/user/projects/backend",
projectName: "backend",
namedWorkspacePath: "/home/user/.cmux/src/backend/api-v2",
},
{
id: "backend-db-migration",
id: "5e6f7a8b9c",
name: "db-migration",
projectPath: "/home/user/projects/backend",
projectName: "backend",
namedWorkspacePath: "/home/user/.cmux/src/backend/db-migration",
},
{
id: "mobile-main",
id: "6f7a8b9c0d",
name: "main",
projectPath: "/home/user/projects/mobile",
projectName: "mobile",
Expand Down Expand Up @@ -351,7 +353,8 @@ export const ActiveWorkspaceWithChat: Story = {
Promise.resolve({
success: true,
metadata: {
id: `${projectPath.split("/").pop() ?? "project"}-${branchName}`,
// Mock stable ID (production uses crypto.randomBytes(5).toString('hex'))
id: Math.random().toString(36).substring(2, 12),
name: branchName,
projectPath,
projectName: projectPath.split("/").pop() ?? "project",
Expand Down
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ function AppInner() {

const openWorkspaceInTerminal = useCallback(
(workspaceId: string) => {
// Look up workspace metadata to get the named path (user-friendly symlink)
// Look up workspace metadata to get the worktree path (directory uses workspace name)
const metadata = workspaceMetadata.get(workspaceId);
if (metadata) {
void window.api.workspace.openTerminal(metadata.namedWorkspacePath);
Expand Down
2 changes: 1 addition & 1 deletion src/components/WorkspaceListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { cn } from "@/lib/utils";
export interface WorkspaceSelection {
projectPath: string;
projectName: string;
namedWorkspacePath: string; // User-friendly path (symlink for new workspaces)
namedWorkspacePath: string; // Worktree path (directory uses workspace name)
workspaceId: string;
}
export interface WorkspaceListItemProps {
Expand Down
5 changes: 3 additions & 2 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,9 @@ describe("Config", () => {
// Create workspace directory
fs.mkdirSync(workspacePath, { recursive: true });

// Create metadata file using legacy ID format (project-workspace)
const legacyId = config.generateWorkspaceId(projectPath, workspacePath);
// Test backward compatibility: Create metadata file using legacy ID format.
// This simulates workspaces created before stable IDs were introduced.
const legacyId = config.generateLegacyId(projectPath, workspacePath);
const sessionDir = config.getSessionDir(legacyId);
fs.mkdirSync(sessionDir, { recursive: true });
const metadataPath = path.join(sessionDir, "metadata.json");
Expand Down
36 changes: 20 additions & 16 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,26 +111,27 @@ export class Config {
}

/**
* DEPRECATED: Generate workspace ID from project and workspace paths.
* This method is used only for legacy workspace migration.
* New workspaces should use generateStableId() instead.
* DEPRECATED: Generate legacy workspace ID from project and workspace paths.
* This method is used only for legacy workspace migration to look up old workspaces.
* New workspaces use generateStableId() which returns a random stable ID.
*
* Format: ${projectBasename}-${workspaceBasename}
* DO NOT use this method or its format to construct workspace IDs anywhere in the codebase.
* Workspace IDs are backend implementation details and must only come from backend operations.
*/
generateWorkspaceId(projectPath: string, workspacePath: string): string {
generateLegacyId(projectPath: string, workspacePath: string): string {
const projectBasename = this.getProjectName(projectPath);
const workspaceBasename =
workspacePath.split("/").pop() ?? workspacePath.split("\\").pop() ?? "unknown";
return `${projectBasename}-${workspaceBasename}`;
}

/**
* Get the workspace worktree path for a given workspace ID.
* New workspaces use stable IDs, legacy workspaces use the old format.
* Get the workspace worktree path for a given directory name.
* The directory name is the workspace name (branch name).
*/
getWorkspacePath(projectPath: string, workspaceId: string): string {
getWorkspacePath(projectPath: string, directoryName: string): string {
const projectName = this.getProjectName(projectPath);
return path.join(this.srcDir, projectName, workspaceId);
return path.join(this.srcDir, projectName, directoryName);
}

/**
Expand Down Expand Up @@ -197,7 +198,7 @@ export class Config {
}

// Try legacy ID format as last resort
const legacyId = this.generateWorkspaceId(projectPath, workspace.path);
const legacyId = this.generateLegacyId(projectPath, workspace.path);
if (legacyId === workspaceId) {
return { workspacePath: workspace.path, projectPath };
}
Expand All @@ -211,10 +212,13 @@ export class Config {
/**
* Workspace Path Architecture:
*
* Workspace paths are computed on-demand from projectPath + workspaceId using
* config.getWorkspacePath(). This ensures single source of truth for path format.
* Workspace paths are computed on-demand from projectPath + workspace name using
* config.getWorkspacePath(projectPath, directoryName). This ensures a single source of truth.
*
* Backend: Uses getWorkspacePath(metadata.projectPath, metadata.name) for directory paths (worktree directories use name)
* - Worktree directory name: uses workspace.name (the branch name)
* - Workspace ID: stable random identifier for identity and sessions (not used for directories)
*
* Backend: Uses getWorkspacePath(metadata.projectPath, metadata.name) for worktree directory paths
* Frontend: Gets enriched metadata with paths via IPC (FrontendWorkspaceMetadata)
*
* WorkspaceMetadata.workspacePath is deprecated and will be removed. Use computed
Expand Down Expand Up @@ -272,7 +276,7 @@ export class Config {

// LEGACY FORMAT: Fall back to reading metadata.json
// Try legacy ID format first (project-workspace) - used by E2E tests and old workspaces
const legacyId = this.generateWorkspaceId(projectPath, workspace.path);
const legacyId = this.generateLegacyId(projectPath, workspace.path);
const metadataPath = path.join(this.getSessionDir(legacyId), "metadata.json");
let metadataFound = false;

Expand Down Expand Up @@ -302,7 +306,7 @@ export class Config {

// No metadata found anywhere - create basic metadata
if (!metadataFound) {
const legacyId = this.generateWorkspaceId(projectPath, workspace.path);
const legacyId = this.generateLegacyId(projectPath, workspace.path);
const metadata: WorkspaceMetadata = {
id: legacyId,
name: workspaceBasename,
Expand All @@ -320,7 +324,7 @@ export class Config {
} catch (error) {
console.error(`Failed to load/migrate workspace metadata:`, error);
// Fallback to basic metadata if migration fails
const legacyId = this.generateWorkspaceId(projectPath, workspace.path);
const legacyId = this.generateLegacyId(projectPath, workspace.path);
const metadata: WorkspaceMetadata = {
id: legacyId,
name: workspaceBasename,
Expand Down
17 changes: 5 additions & 12 deletions src/debug/agentSessionCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,19 +160,12 @@ async function main(): Promise<void> {
const config = new Config(configRoot);

const workspaceIdRaw = values["workspace-id"];
const projectPathRaw = values["project-path"];

const workspaceId =
workspaceIdRaw && workspaceIdRaw.trim().length > 0
? workspaceIdRaw.trim()
: (() => {
if (typeof projectPathRaw !== "string" || projectPathRaw.trim().length === 0) {
throw new Error("Provide --workspace-id or --project-path to derive workspace ID");
}
const projectPath = path.resolve(projectPathRaw.trim());
return config.generateWorkspaceId(projectPath, workspacePath);
})();
if (typeof workspaceIdRaw !== "string" || workspaceIdRaw.trim().length === 0) {
throw new Error("--workspace-id is required");
}
const workspaceId = workspaceIdRaw.trim();

const projectPathRaw = values["project-path"];
const projectName =
typeof projectPathRaw === "string" && projectPathRaw.trim().length > 0
? path.basename(path.resolve(projectPathRaw.trim()))
Expand Down
5 changes: 4 additions & 1 deletion src/debug/git-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ function findWorkspaces(): Array<{ id: string; path: string }> {
const workspacePath = join(projectPath, branch);
if (statSync(workspacePath).isDirectory()) {
workspaces.push({
id: `${project}-${branch}`,
// NOTE: Using directory name as display ID for debug purposes only.
// This is NOT how workspace IDs are determined in production code.
// Production workspace IDs come from metadata.json in the session dir.
id: branch,
path: workspacePath,
});
}
Expand Down
10 changes: 8 additions & 2 deletions src/debug/list-workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,14 @@ export function listWorkspacesCommand() {
console.log(` Workspaces: ${project.workspaces.length}`);

for (const workspace of project.workspaces) {
const workspaceId = defaultConfig.generateWorkspaceId(projectPath, workspace.path);
console.log(` - ID: ${workspaceId} (generated)`);
const dirName = path.basename(workspace.path);
console.log(` - Directory: ${dirName}`);
if (workspace.id) {
console.log(` ID: ${workspace.id}`);
}
if (workspace.name) {
console.log(` Name: ${workspace.name}`);
}
console.log(` Path: ${workspace.path}`);
console.log(` Exists: ${fs.existsSync(workspace.path)}`);
}
Expand Down
8 changes: 4 additions & 4 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface WorktreeResult {
export interface CreateWorktreeOptions {
trunkBranch: string;
/** Directory name to use for the worktree (if not provided, uses branchName) */
workspaceId?: string;
directoryName?: string;
}

export async function listLocalBranches(projectPath: string): Promise<string[]> {
Expand Down Expand Up @@ -76,9 +76,9 @@ export async function createWorktree(
options: CreateWorktreeOptions
): Promise<WorktreeResult> {
try {
// Use workspaceId for directory name if provided, otherwise fall back to branchName (legacy)
const directoryName = options.workspaceId ?? branchName;
const workspacePath = config.getWorkspacePath(projectPath, directoryName);
// Use directoryName if provided, otherwise fall back to branchName (legacy)
const dirName = options.directoryName ?? branchName;
const workspacePath = config.getWorkspacePath(projectPath, dirName);
const { trunkBranch } = options;
const normalizedTrunkBranch = typeof trunkBranch === "string" ? trunkBranch.trim() : "";

Expand Down
4 changes: 2 additions & 2 deletions src/services/ipcMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ export class IpcMain {
// Create the git worktree with the workspace name as directory name
const result = await createWorktree(this.config, projectPath, branchName, {
trunkBranch: normalizedTrunkBranch,
workspaceId: branchName, // Use name for directory (workspaceId param is misnamed, it's directoryName)
directoryName: branchName,
});

if (result.success && result.path) {
Expand Down Expand Up @@ -504,7 +504,7 @@ export class IpcMain {
// Create new git worktree branching from source workspace's branch
const result = await createWorktree(this.config, foundProjectPath, newName, {
trunkBranch: sourceBranch,
workspaceId: newName, // Use name for directory (workspaceId param is misnamed, it's directoryName)
directoryName: newName,
});

if (!result.success || !result.path) {
Expand Down
4 changes: 2 additions & 2 deletions src/types/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ export const WorkspaceMetadataSchema = z.object({
* - name is the branch/workspace name (e.g., "feature-branch")
*
* Path handling:
* - Worktree paths are computed on-demand via config.getWorkspacePath(projectPath, id)
* - Worktree paths are computed on-demand via config.getWorkspacePath(projectPath, name)
* - Directory name uses workspace.name (the branch name)
* - This avoids storing redundant derived data
* - Frontend can show symlink paths, backend uses real paths
*/
export interface WorkspaceMetadata {
/** Stable unique identifier (10 hex chars for new workspaces, legacy format for old) */
Expand Down
2 changes: 1 addition & 1 deletion src/utils/ui/workspaceFiltering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe("partitionWorkspacesByAge", () => {
name: `workspace-${id}`,
projectName: "test-project",
projectPath: "/test/project",
namedWorkspacePath: `/test/project/.worktrees/${id}`,
namedWorkspacePath: `/test/project/workspace-${id}`, // Path is arbitrary for this test
});

it("should partition workspaces into recent and old based on 24-hour threshold", () => {
Expand Down
5 changes: 3 additions & 2 deletions tests/e2e/utils/demoProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ export function prepareDemoProject(
fs.mkdirSync(workspacePath, { recursive: true });
fs.mkdirSync(sessionsDir, { recursive: true });

// Workspace metadata mirrors Config.generateWorkspaceId
// E2E tests use legacy workspace ID format to test backward compatibility.
// Production code now uses generateStableId() for new workspaces.
const config = new Config(rootDir);
const workspaceId = config.generateWorkspaceId(projectPath, workspacePath);
const workspaceId = config.generateLegacyId(projectPath, workspacePath);
const metadata = {
id: workspaceId,
name: workspaceBranch,
Expand Down
Loading