Skip to content

Commit 5addf3b

Browse files
author
Coder
committed
feat: support '/' character in Git branch names used as workspace names
Allow forward slashes in workspace names (e.g. 'feature/my-branch') to match common Git branch naming conventions. Changes: - Update validation regex to accept '/' (reject leading, trailing, and consecutive slashes) - Add sanitizeWorkspaceNameForPath() that replaces '/' with '-' for filesystem paths - Apply sanitization in getWorkspacePath() for WorktreeManager and SSHRuntime, with an absolute-path guard for in-place workspaces - Add path collision detection in create(), rename(), and fork() scoped to path-based runtimes (worktree, SSH, devcontainer) using usesPathBasedDirs() helper - Auto-fork collision retry loop scales with existing workspace count and uses normalized parent for legacy name compatibility - Handle slashes in CoderSSHRuntime.toCoderCompatibleName() - Ensure movePlanFile() creates destination directories via mkdir -p - Same-path rename short-circuit runs best-effort git branch -m - Update mobile validation, UI hints, and add comprehensive tests Closes #407
1 parent 95bfca1 commit 5addf3b

File tree

11 files changed

+348
-25
lines changed

11 files changed

+348
-25
lines changed

mobile/src/components/RenameWorkspaceModal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,8 @@ export function RenameWorkspaceModal({
253253
variant="caption"
254254
style={{ marginTop: spacing.sm, color: theme.colors.foregroundMuted }}
255255
>
256-
Only lowercase letters, digits, underscore, and hyphen (1-64 characters)
256+
Only lowercase letters, digits, underscore, hyphen, and forward slash (1-64
257+
characters, no leading/trailing/consecutive slashes)
257258
</ThemedText>
258259
)}
259260
</View>

mobile/src/utils/workspaceValidation.test.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "bun:test";
2-
import { validateWorkspaceName } from "./workspaceValidation";
2+
import { validateWorkspaceName, sanitizeWorkspaceNameForPath } from "./workspaceValidation";
33

44
describe("validateWorkspaceName", () => {
55
describe("empty names", () => {
@@ -55,6 +55,12 @@ describe("validateWorkspaceName", () => {
5555
expect(validateWorkspaceName("test-workspace-name")).toEqual({ valid: true });
5656
});
5757

58+
it("accepts forward slashes in branch-style names", () => {
59+
expect(validateWorkspaceName("feature/my-branch")).toEqual({ valid: true });
60+
expect(validateWorkspaceName("fix/issue-123")).toEqual({ valid: true });
61+
expect(validateWorkspaceName("user/feature/deep")).toEqual({ valid: true });
62+
});
63+
5864
it("accepts mixed valid characters", () => {
5965
expect(validateWorkspaceName("feature-branch_123")).toEqual({ valid: true });
6066
expect(validateWorkspaceName("fix-001")).toEqual({ valid: true });
@@ -73,6 +79,18 @@ describe("validateWorkspaceName", () => {
7379
expect(result.error).toContain("lowercase letters");
7480
});
7581

82+
it("rejects leading slash", () => {
83+
expect(validateWorkspaceName("/feature").valid).toBe(false);
84+
});
85+
86+
it("rejects trailing slash", () => {
87+
expect(validateWorkspaceName("feature/").valid).toBe(false);
88+
});
89+
90+
it("rejects consecutive slashes", () => {
91+
expect(validateWorkspaceName("feature//branch").valid).toBe(false);
92+
});
93+
7694
it("rejects special characters", () => {
7795
const invalidNames = [
7896
"test!",
@@ -92,7 +110,6 @@ describe("validateWorkspaceName", () => {
92110
"test}",
93111
"test|",
94112
"test\\",
95-
"test/",
96113
"test?",
97114
"test<",
98115
"test>",
@@ -126,3 +143,25 @@ describe("validateWorkspaceName", () => {
126143
});
127144
});
128145
});
146+
147+
describe("sanitizeWorkspaceNameForPath", () => {
148+
it("returns name unchanged when no slashes", () => {
149+
expect(sanitizeWorkspaceNameForPath("my-branch")).toBe("my-branch");
150+
});
151+
152+
it("replaces single slash with hyphen", () => {
153+
expect(sanitizeWorkspaceNameForPath("feature/my-branch")).toBe("feature-my-branch");
154+
});
155+
156+
it("replaces multiple slashes in deep paths", () => {
157+
expect(sanitizeWorkspaceNameForPath("user/feature/deep")).toBe("user-feature-deep");
158+
});
159+
160+
it("preserves existing consecutive hyphens", () => {
161+
expect(sanitizeWorkspaceNameForPath("feature--branch")).toBe("feature--branch");
162+
});
163+
164+
it("does not collapse hyphens adjacent to replaced slash", () => {
165+
expect(sanitizeWorkspaceNameForPath("feature/-branch")).toBe("feature--branch");
166+
});
167+
});
Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
/**
22
* Validates workspace name format
33
* - Must be 1-64 characters long
4-
* - Can only contain: lowercase letters, digits, underscore, hyphen
5-
* - Pattern: [a-z0-9_-]{1,64}
4+
* - Can only contain: lowercase letters, digits, underscore, hyphen, forward slash
5+
* - No leading, trailing, or consecutive slashes
6+
* - Pattern: [a-z0-9_-]+(?:\/[a-z0-9_-]+)* (1-64 characters)
67
*/
78
export function validateWorkspaceName(name: string): { valid: boolean; error?: string } {
89
if (!name || name.length === 0) {
@@ -13,13 +14,22 @@ export function validateWorkspaceName(name: string): { valid: boolean; error?: s
1314
return { valid: false, error: "Workspace name cannot exceed 64 characters" };
1415
}
1516

16-
const validPattern = /^[a-z0-9_-]+$/;
17+
const validPattern = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)*$/;
1718
if (!validPattern.test(name)) {
1819
return {
1920
valid: false,
20-
error: "Workspace name can only contain lowercase letters, digits, underscore, and hyphen",
21+
error:
22+
"Workspace names can only contain lowercase letters, numbers, hyphens, underscores, and forward slashes (no leading, trailing, or consecutive slashes)",
2123
};
2224
}
2325

2426
return { valid: true };
2527
}
28+
29+
/**
30+
* Convert a workspace name to a filesystem-safe path component by replacing
31+
* forward slashes with hyphens.
32+
*/
33+
export function sanitizeWorkspaceNameForPath(name: string): string {
34+
return name.replace(/\//g, "-");
35+
}

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

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

33
describe("validateWorkspaceName", () => {
44
describe("valid names", () => {
@@ -22,6 +22,12 @@ describe("validateWorkspaceName", () => {
2222
expect(validateWorkspaceName("feature-test-123").valid).toBe(true);
2323
});
2424

25+
test("accepts forward slashes in branch-style names", () => {
26+
expect(validateWorkspaceName("feature/my-branch").valid).toBe(true);
27+
expect(validateWorkspaceName("fix/issue-123").valid).toBe(true);
28+
expect(validateWorkspaceName("user/feature/deep").valid).toBe(true);
29+
});
30+
2531
test("accepts combinations", () => {
2632
expect(validateWorkspaceName("feature-branch_123").valid).toBe(true);
2733
expect(validateWorkspaceName("a1-b2_c3").valid).toBe(true);
@@ -72,13 +78,45 @@ describe("validateWorkspaceName", () => {
7278
expect(validateWorkspaceName("branch%123").valid).toBe(false);
7379
expect(validateWorkspaceName("branch!123").valid).toBe(false);
7480
expect(validateWorkspaceName("branch.123").valid).toBe(false);
75-
expect(validateWorkspaceName("branch/123").valid).toBe(false);
7681
expect(validateWorkspaceName("branch\\123").valid).toBe(false);
7782
});
7883

79-
test("rejects names with slashes", () => {
80-
expect(validateWorkspaceName("feature/branch").valid).toBe(false);
84+
test("rejects leading slash", () => {
85+
expect(validateWorkspaceName("/feature").valid).toBe(false);
86+
});
87+
88+
test("rejects trailing slash", () => {
89+
expect(validateWorkspaceName("feature/").valid).toBe(false);
90+
});
91+
92+
test("rejects consecutive slashes", () => {
93+
expect(validateWorkspaceName("feature//branch").valid).toBe(false);
94+
});
95+
96+
test("rejects backslashes", () => {
8197
expect(validateWorkspaceName("path\\to\\branch").valid).toBe(false);
8298
});
8399
});
84100
});
101+
102+
describe("sanitizeWorkspaceNameForPath", () => {
103+
test("returns name unchanged when no slashes", () => {
104+
expect(sanitizeWorkspaceNameForPath("my-branch")).toBe("my-branch");
105+
});
106+
107+
test("replaces single slash with hyphen", () => {
108+
expect(sanitizeWorkspaceNameForPath("feature/my-branch")).toBe("feature-my-branch");
109+
});
110+
111+
test("replaces multiple slashes in deep paths", () => {
112+
expect(sanitizeWorkspaceNameForPath("user/feature/deep")).toBe("user-feature-deep");
113+
});
114+
115+
test("preserves existing consecutive hyphens", () => {
116+
expect(sanitizeWorkspaceNameForPath("feature--branch")).toBe("feature--branch");
117+
});
118+
119+
test("does not collapse hyphens adjacent to replaced slash", () => {
120+
expect(sanitizeWorkspaceNameForPath("feature/-branch")).toBe("feature--branch");
121+
});
122+
});
Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
/**
22
* Validates workspace name format
33
* - Must be 1-64 characters long
4-
* - Can only contain: lowercase letters, digits, underscore, hyphen
5-
* - Pattern: [a-z0-9_-]{1,64}
4+
* - Can only contain: lowercase letters, digits, underscore, hyphen, forward slash
5+
* - No leading, trailing, or consecutive slashes
6+
* - Pattern: [a-z0-9_-]+(?:\/[a-z0-9_-]+)* (1-64 characters)
67
*/
78
export function validateWorkspaceName(name: string): { valid: boolean; error?: string } {
89
if (!name || name.length === 0) {
@@ -13,16 +14,25 @@ export function validateWorkspaceName(name: string): { valid: boolean; error?: s
1314
return { valid: false, error: "Workspace name cannot exceed 64 characters" };
1415
}
1516

16-
const validPattern = /^[a-z0-9_-]+$/;
17+
const validPattern = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)*$/;
1718
if (!validPattern.test(name)) {
1819
return {
1920
valid: false,
20-
// Workspace names become folder names, git branches, and session directories,
21-
// so they need to be filesystem-safe across platforms.
2221
error:
23-
"Workspace names can only contain lowercase letters, numbers, hyphens, and underscores",
22+
"Workspace names can only contain lowercase letters, numbers, hyphens, underscores, and forward slashes (no leading, trailing, or consecutive slashes)",
2423
};
2524
}
2625

2726
return { valid: true };
2827
}
28+
29+
/**
30+
* Convert a workspace name to a filesystem-safe path component by replacing
31+
* forward slashes with hyphens.
32+
*
33+
* This allows git-style branch names like "feature/my-branch" to be used as
34+
* workspace names while remaining safe for directory names and session paths.
35+
*/
36+
export function sanitizeWorkspaceNameForPath(name: string): string {
37+
return name.replace(/\//g, "-");
38+
}

src/node/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { isValidModelFormat, normalizeGatewayModel } from "@/common/utils/ai/mod
3939
import { ensurePrivateDirSync } from "@/node/utils/fs";
4040
import { stripTrailingSlashes } from "@/node/utils/pathUtils";
4141
import { getContainerName as getDockerContainerName } from "@/node/runtime/DockerRuntime";
42+
import { sanitizeWorkspaceNameForPath } from "@/common/utils/validation/workspaceValidation";
4243

4344
// Re-export project/provider types from dedicated schema/types files (for preload usage)
4445
export type { Workspace, ProjectConfig, ProjectsConfig, ProviderConfig, CanonicalProvidersConfig };
@@ -1060,7 +1061,7 @@ export class Config {
10601061
// otherwise fall back to worktree-style path for legacy compatibility
10611062
const projectName = this.getProjectName(projectPath);
10621063
const workspacePath =
1063-
metadata.namedWorkspacePath ?? path.join(this.srcDir, projectName, metadata.name);
1064+
metadata.namedWorkspacePath ?? path.join(this.srcDir, projectName, path.isAbsolute(metadata.name) ? metadata.name : sanitizeWorkspaceNameForPath(metadata.name));
10641065
const workspaceEntry: Workspace = {
10651066
path: workspacePath,
10661067
id: metadata.id,

src/node/runtime/CoderSSHRuntime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const CODER_NAME_REGEX = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/;
5555
*/
5656
function toCoderCompatibleName(name: string): string {
5757
return name
58+
.replace(/\//g, "-") // Replace slashes with hyphens (branch-style names)
5859
.replace(/_/g, "-") // Replace underscores with hyphens
5960
.replace(/^-+|-+$/g, "") // Remove leading/trailing hyphens
6061
.replace(/-{2,}/g, "-"); // Collapse multiple hyphens

src/node/runtime/SSHRuntime.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { getOriginUrlForBundle } from "./gitBundleSync";
4848
import { gitNoHooksPrefix } from "@/node/utils/gitNoHooksEnv";
4949
import type { PtyHandle, PtySessionParams, SSHTransport } from "./transports";
5050
import { streamToString, shescape } from "./streamUtils";
51+
import { sanitizeWorkspaceNameForPath } from "@/common/utils/validation/workspaceValidation";
5152

5253
/** Name of the shared bare repo directory under each project on the remote. */
5354
const BASE_REPO_DIR = ".mux-base.git";
@@ -338,7 +339,13 @@ export class SSHRuntime extends RemoteRuntime {
338339

339340
getWorkspacePath(projectPath: string, workspaceName: string): string {
340341
const projectName = getProjectName(projectPath);
341-
return path.posix.join(this.config.srcBaseDir, projectName, workspaceName);
342+
// In-place workspaces use absolute paths as workspace names — do not sanitize
343+
// those since path.posix.join already handles them correctly and sanitizing
344+
// would turn e.g. "/home/user/project" into "-home-user-project".
345+
const safeName = path.posix.isAbsolute(workspaceName)
346+
? workspaceName
347+
: sanitizeWorkspaceNameForPath(workspaceName);
348+
return path.posix.join(this.config.srcBaseDir, projectName, safeName);
342349
}
343350

344351
/**
@@ -1147,6 +1154,27 @@ export class SSHRuntime extends RemoteRuntime {
11471154
const oldPath = this.getWorkspacePath(projectPath, oldName);
11481155
const newPath = this.getWorkspacePath(projectPath, newName);
11491156

1157+
// Short-circuit: if the sanitized paths are identical (e.g. renaming
1158+
// "feature-a" <-> "feature/a"), skip the directory move — only the
1159+
// persisted name changes, not the physical location.
1160+
if (oldPath === newPath) {
1161+
// Still rename the git branch to maintain the workspace/branch name sync.
1162+
// Best-effort: branch rename can fail if the workspace is in detached HEAD
1163+
// state or if the branch has drifted, matching the normal rename path.
1164+
try {
1165+
const expandedOldPath = expandTildeForSSH(oldPath);
1166+
const branchStream = await this.exec(
1167+
`git -C ${expandedOldPath} branch -m ${shescape.quote(oldName)} ${shescape.quote(newName)}`,
1168+
{ cwd: this.config.srcBaseDir, timeout: 10, abortSignal }
1169+
);
1170+
await branchStream.stdin.abort();
1171+
await branchStream.exitCode;
1172+
} catch {
1173+
// Best-effort: branch rename can fail (detached HEAD, branch drift, etc.)
1174+
}
1175+
return { success: true, oldPath, newPath };
1176+
}
1177+
11501178
try {
11511179
const expandedOldPath = expandTildeForSSH(oldPath);
11521180
const expandedNewPath = expandTildeForSSH(newPath);

0 commit comments

Comments
 (0)