Skip to content

Commit 18c14dc

Browse files
committed
🤖 feat: allow workspaces on git branches with slashes (e.g. feature/foo)
Enable creating and opening workspaces on existing git branches that contain forward slashes, a common naming convention like `feature/foo` or `bugfix/issue-123`. - Add `encodeWorkspaceNameForDir()` helper that uses `encodeURIComponent()` - `feature/foo` → directory `feature%2Ffoo` (not nested directories) - Simple names like `main` remain unchanged - Applied in WorktreeRuntime, SSHRuntime, and Config.addWorkspace() - Add `validateGitBranchName()` for worktree/SSH runtimes - Allows forward slashes (plus existing charset: a-z, 0-9, _, -) - Rejects: leading/trailing slashes, consecutive slashes, 64+ chars - Original `validateWorkspaceName()` still used for local runtime - workspaceService.create/rename/fork now choose validator based on runtime type - Git runtimes (worktree/SSH) use slash-permitting validation - Local runtime uses strict validation - Command palette now uses `metadata.name` for display (not path derivation) - Correctly displays `feature/foo` instead of `foo` or `feature%2Ffoo` - Existing workspaces continue to work (no migration needed) - Old workspace paths without encoding are handled correctly - Legacy fallback paths in config.ts unchanged Signed-off-by: Thomas Kosiewski <tk@coder.com> --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_ Change-Id: I650cebc612b2111cff8ae75a94a6a50fe7217165
1 parent 2334b3d commit 18c14dc

File tree

10 files changed

+256
-27
lines changed

10 files changed

+256
-27
lines changed

src/browser/utils/commands/sources.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,10 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi
209209

210210
// Remove current workspace (rename action intentionally omitted until we add a proper modal)
211211
if (selected?.namedWorkspacePath) {
212-
const workspaceDisplayName = `${selected.projectName}/${selected.namedWorkspacePath.split("/").pop() ?? selected.namedWorkspacePath}`;
213212
const selectedMeta = p.workspaceMetadata.get(selected.workspaceId);
213+
// Use metadata.name if available (handles slashes correctly), fallback to path derivation
214+
const workspaceName = selectedMeta?.name ?? selected.namedWorkspacePath;
215+
const workspaceDisplayName = `${selected.projectName}/${workspaceName}`;
214216
list.push({
215217
id: CommandIds.workspaceOpenTerminalCurrent(),
216218
title: "Open Current Workspace in Terminal",

src/common/utils/validation/workspaceValidation.test.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { validateWorkspaceName } from "./workspaceValidation";
1+
import { validateWorkspaceName, validateGitBranchName } from "./workspaceValidation";
22

33
describe("validateWorkspaceName", () => {
44
describe("valid names", () => {
@@ -82,3 +82,81 @@ describe("validateWorkspaceName", () => {
8282
});
8383
});
8484
});
85+
86+
describe("validateGitBranchName", () => {
87+
describe("valid names", () => {
88+
test("accepts simple names (same as validateWorkspaceName)", () => {
89+
expect(validateGitBranchName("main").valid).toBe(true);
90+
expect(validateGitBranchName("feature").valid).toBe(true);
91+
expect(validateGitBranchName("my-branch").valid).toBe(true);
92+
expect(validateGitBranchName("my_branch").valid).toBe(true);
93+
expect(validateGitBranchName("branch123").valid).toBe(true);
94+
});
95+
96+
test("accepts forward slashes (unlike validateWorkspaceName)", () => {
97+
expect(validateGitBranchName("feature/foo").valid).toBe(true);
98+
expect(validateGitBranchName("feature/sub/deep").valid).toBe(true);
99+
expect(validateGitBranchName("bugfix/issue-123").valid).toBe(true);
100+
expect(validateGitBranchName("release/v1_0").valid).toBe(true); // dots not allowed, use underscore
101+
});
102+
103+
test("accepts 64 characters", () => {
104+
const name = "a".repeat(64);
105+
expect(validateGitBranchName(name).valid).toBe(true);
106+
});
107+
108+
test("accepts 64 characters with slashes", () => {
109+
// 31 chars + "/" + 32 chars = 64 chars
110+
const name = "a".repeat(31) + "/" + "b".repeat(32);
111+
expect(validateGitBranchName(name).valid).toBe(true);
112+
});
113+
});
114+
115+
describe("invalid names", () => {
116+
test("rejects empty string", () => {
117+
const result = validateGitBranchName("");
118+
expect(result.valid).toBe(false);
119+
expect(result.error).toContain("empty");
120+
});
121+
122+
test("rejects names over 64 characters", () => {
123+
const name = "a".repeat(65);
124+
const result = validateGitBranchName(name);
125+
expect(result.valid).toBe(false);
126+
expect(result.error).toContain("64 characters");
127+
});
128+
129+
test("rejects leading slash", () => {
130+
const result = validateGitBranchName("/feature");
131+
expect(result.valid).toBe(false);
132+
expect(result.error).toContain("start or end with /");
133+
});
134+
135+
test("rejects trailing slash", () => {
136+
const result = validateGitBranchName("feature/");
137+
expect(result.valid).toBe(false);
138+
expect(result.error).toContain("start or end with /");
139+
});
140+
141+
test("rejects consecutive slashes", () => {
142+
const result = validateGitBranchName("feature//foo");
143+
expect(result.valid).toBe(false);
144+
expect(result.error).toContain("consecutive slashes");
145+
});
146+
147+
test("rejects uppercase letters", () => {
148+
const result = validateGitBranchName("Feature/Foo");
149+
expect(result.valid).toBe(false);
150+
expect(result.error).toContain("a-z");
151+
});
152+
153+
test("rejects special characters (except slash)", () => {
154+
expect(validateGitBranchName("branch@123").valid).toBe(false);
155+
expect(validateGitBranchName("branch#123").valid).toBe(false);
156+
expect(validateGitBranchName("branch$123").valid).toBe(false);
157+
expect(validateGitBranchName("branch%123").valid).toBe(false);
158+
expect(validateGitBranchName("branch.123").valid).toBe(false);
159+
expect(validateGitBranchName("branch\\123").valid).toBe(false);
160+
});
161+
});
162+
});

src/common/utils/validation/workspaceValidation.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Validates workspace name format
2+
* Validates workspace name format (for non-git runtimes)
33
* - Must be 1-64 characters long
44
* - Can only contain: lowercase letters, digits, underscore, hyphen
55
* - Pattern: [a-z0-9_-]{1,64}
@@ -23,3 +23,47 @@ export function validateWorkspaceName(name: string): { valid: boolean; error?: s
2323

2424
return { valid: true };
2525
}
26+
27+
/**
28+
* Validates git branch names for worktree/SSH runtimes
29+
* - Must be 1-64 characters long
30+
* - Can only contain: lowercase letters, digits, underscore, hyphen, forward slash
31+
* - Cannot start or end with slash
32+
* - Cannot have consecutive slashes
33+
* - Pattern: [a-z0-9_-]+(/[a-z0-9_-]+)*
34+
*
35+
* This allows common branch naming conventions like:
36+
* - feature/foo
37+
* - bugfix/issue-123
38+
* - release/v1_0 (note: dots are not supported, use underscores)
39+
*/
40+
export function validateGitBranchName(name: string): { valid: boolean; error?: string } {
41+
if (!name || name.length === 0) {
42+
return { valid: false, error: "Branch name cannot be empty" };
43+
}
44+
45+
if (name.length > 64) {
46+
return { valid: false, error: "Branch name cannot exceed 64 characters" };
47+
}
48+
49+
// Check for leading/trailing slashes
50+
if (name.startsWith("/") || name.endsWith("/")) {
51+
return { valid: false, error: "Branch name cannot start or end with /" };
52+
}
53+
54+
// Check for consecutive slashes
55+
if (name.includes("//")) {
56+
return { valid: false, error: "Branch name cannot contain consecutive slashes" };
57+
}
58+
59+
// Pattern: one or more segments of [a-z0-9_-]+ separated by single slashes
60+
const validPattern = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)*$/;
61+
if (!validPattern.test(name)) {
62+
return {
63+
valid: false,
64+
error: "Use only: a-z, 0-9, _, -, /",
65+
};
66+
}
67+
68+
return { valid: true };
69+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { encodeWorkspaceNameForDir, decodeWorkspaceNameFromDir } from "./workspaceDirName";
2+
3+
describe("encodeWorkspaceNameForDir", () => {
4+
test("leaves simple names unchanged", () => {
5+
expect(encodeWorkspaceNameForDir("main")).toBe("main");
6+
expect(encodeWorkspaceNameForDir("feature")).toBe("feature");
7+
expect(encodeWorkspaceNameForDir("my-branch")).toBe("my-branch");
8+
expect(encodeWorkspaceNameForDir("my_branch")).toBe("my_branch");
9+
expect(encodeWorkspaceNameForDir("branch123")).toBe("branch123");
10+
});
11+
12+
test("encodes forward slashes", () => {
13+
expect(encodeWorkspaceNameForDir("feature/foo")).toBe("feature%2Ffoo");
14+
expect(encodeWorkspaceNameForDir("feature/sub/deep")).toBe("feature%2Fsub%2Fdeep");
15+
});
16+
17+
test("encodes other special characters", () => {
18+
// These are less common but encodeURIComponent handles them
19+
expect(encodeWorkspaceNameForDir("test@branch")).toBe("test%40branch");
20+
expect(encodeWorkspaceNameForDir("test#branch")).toBe("test%23branch");
21+
});
22+
});
23+
24+
describe("decodeWorkspaceNameFromDir", () => {
25+
test("decodes encoded names back to original", () => {
26+
expect(decodeWorkspaceNameFromDir("feature%2Ffoo")).toBe("feature/foo");
27+
expect(decodeWorkspaceNameFromDir("feature%2Fsub%2Fdeep")).toBe("feature/sub/deep");
28+
});
29+
30+
test("leaves unencoded names unchanged", () => {
31+
expect(decodeWorkspaceNameFromDir("main")).toBe("main");
32+
expect(decodeWorkspaceNameFromDir("my-branch")).toBe("my-branch");
33+
});
34+
35+
test("roundtrip: encode then decode returns original", () => {
36+
const names = ["main", "feature/foo", "feature/sub/deep", "my-branch_123"];
37+
for (const name of names) {
38+
expect(decodeWorkspaceNameFromDir(encodeWorkspaceNameForDir(name))).toBe(name);
39+
}
40+
});
41+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Encodes a workspace name for safe use as a filesystem directory name.
3+
*
4+
* Uses encodeURIComponent which:
5+
* - Is deterministic and collision-free
6+
* - Leaves simple names unchanged (e.g., "main", "foo-bar")
7+
* - Converts slashes: "feature/foo" → "feature%2Ffoo"
8+
*
9+
* This ensures workspace names like "feature/foo" don't create nested directories.
10+
*/
11+
export function encodeWorkspaceNameForDir(workspaceName: string): string {
12+
return encodeURIComponent(workspaceName);
13+
}
14+
15+
/**
16+
* Decodes a filesystem directory name back to the original workspace name.
17+
*
18+
* Useful for debugging and diagnostics. Not needed for normal runtime operations
19+
* since we always work with the original workspace name from metadata.
20+
*/
21+
export function decodeWorkspaceNameFromDir(dirName: string): string {
22+
return decodeURIComponent(dirName);
23+
}

src/node/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility
1212
import { getMuxHome } from "@/common/constants/paths";
1313
import { PlatformPaths } from "@/common/utils/paths";
1414
import { stripTrailingSlashes } from "@/node/utils/pathUtils";
15+
import { encodeWorkspaceNameForDir } from "@/common/utils/workspaceDirName";
1516

1617
// Re-export project types from dedicated types file (for preload usage)
1718
export type { Workspace, ProjectConfig, ProjectsConfig };
@@ -411,7 +412,8 @@ export class Config {
411412
// Compute workspace path - this is only for legacy config migration
412413
// New code should use Runtime.getWorkspacePath() directly
413414
const projectName = this.getProjectName(projectPath);
414-
const workspacePath = path.join(this.srcDir, projectName, metadata.name);
415+
const dirName = encodeWorkspaceNameForDir(metadata.name);
416+
const workspacePath = path.join(this.srcDir, projectName, dirName);
415417
const workspaceEntry: Workspace = {
416418
path: workspacePath,
417419
id: metadata.id,

src/node/runtime/SSHRuntime.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { getErrorMessage } from "@/common/utils/errors";
2626
import { execAsync, DisposableProcess } from "@/node/utils/disposableExec";
2727
import { getControlPath, sshConnectionPool, type SSHRuntimeConfig } from "./sshConnectionPool";
2828
import { getBashPath } from "@/node/utils/main/bashPath";
29+
import { encodeWorkspaceNameForDir } from "@/common/utils/workspaceDirName";
2930

3031
/**
3132
* Shell-escape helper for remote bash.
@@ -845,7 +846,8 @@ export class SSHRuntime implements Runtime {
845846

846847
getWorkspacePath(projectPath: string, workspaceName: string): string {
847848
const projectName = getProjectName(projectPath);
848-
return path.posix.join(this.config.srcBaseDir, projectName, workspaceName);
849+
const dirName = encodeWorkspaceNameForDir(workspaceName);
850+
return path.posix.join(this.config.srcBaseDir, projectName, dirName);
849851
}
850852

851853
async createWorkspace(params: WorkspaceCreationParams): Promise<WorkspaceCreationResult> {

src/node/runtime/WorktreeRuntime.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,23 @@ describe("WorktreeRuntime constructor", () => {
2828
const expected = path.join(os.homedir(), "project", "branch");
2929
expect(workspacePath).toBe(expected);
3030
});
31+
32+
it("should encode slashes in workspace names", () => {
33+
const runtime = new WorktreeRuntime("/absolute/path");
34+
const workspacePath = runtime.getWorkspacePath("/home/user/project", "feature/foo");
35+
36+
// Slash should be encoded as %2F, not create nested directories
37+
const expected = path.join("/absolute/path", "project", "feature%2Ffoo");
38+
expect(workspacePath).toBe(expected);
39+
});
40+
41+
it("should encode multiple slashes in workspace names", () => {
42+
const runtime = new WorktreeRuntime("/absolute/path");
43+
const workspacePath = runtime.getWorkspacePath("/home/user/project", "feature/sub/deep");
44+
45+
const expected = path.join("/absolute/path", "project", "feature%2Fsub%2Fdeep");
46+
expect(workspacePath).toBe(expected);
47+
});
3148
});
3249

3350
describe("WorktreeRuntime.resolvePath", () => {

src/node/runtime/WorktreeRuntime.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@ import { getErrorMessage } from "@/common/utils/errors";
1818
import { expandTilde } from "./tildeExpansion";
1919
import { LocalBaseRuntime } from "./LocalBaseRuntime";
2020
import { toPosixPath } from "@/node/utils/paths";
21+
import { encodeWorkspaceNameForDir } from "@/common/utils/workspaceDirName";
2122

2223
/**
2324
* Worktree runtime implementation that executes commands and file operations
2425
* directly on the host machine using Node.js APIs.
2526
*
2627
* This runtime uses git worktrees for workspace isolation:
27-
* - Workspaces are created in {srcBaseDir}/{projectName}/{workspaceName}
28+
* - Workspaces are created in {srcBaseDir}/{projectName}/{encodedWorkspaceName}
2829
* - Each workspace is a git worktree with its own branch
30+
* - Workspace names containing "/" are encoded (e.g., "feature/foo" → "feature%2Ffoo")
2931
*/
3032
export class WorktreeRuntime extends LocalBaseRuntime {
3133
private readonly srcBaseDir: string;
@@ -38,7 +40,8 @@ export class WorktreeRuntime extends LocalBaseRuntime {
3840

3941
getWorkspacePath(projectPath: string, workspaceName: string): string {
4042
const projectName = getProjectName(projectPath);
41-
return path.join(this.srcBaseDir, projectName, workspaceName);
43+
const dirName = encodeWorkspaceNameForDir(workspaceName);
44+
return path.join(this.srcBaseDir, projectName, dirName);
4245
}
4346

4447
async createWorkspace(params: WorkspaceCreationParams): Promise<WorkspaceCreationResult> {

0 commit comments

Comments
 (0)