Skip to content

Commit 48ca952

Browse files
authored
🤖 fix: strip trailing slashes from project paths (#1065)
Fixes #1063 Project paths with trailing slashes (e.g. `/home/user/project/`) caused mangled workspace names because `basename('/path/')` returns an empty string. The fix strips trailing slashes in `validateProjectPath()`, which is the central validation point for all project paths. --- _Generated with `mux`_
1 parent f6fda01 commit 48ca952

File tree

5 files changed

+152
-5
lines changed

5 files changed

+152
-5
lines changed

src/node/config.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,32 @@ describe("Config", () => {
1818
fs.rmSync(tempDir, { recursive: true, force: true });
1919
});
2020

21+
describe("loadConfigOrDefault with trailing slash migration", () => {
22+
it("should strip trailing slashes from project paths on load", () => {
23+
// Create config file with trailing slashes in project paths
24+
const configFile = path.join(tempDir, "config.json");
25+
const corruptedConfig = {
26+
projects: [
27+
["/home/user/project/", { workspaces: [] }],
28+
["/home/user/another//", { workspaces: [] }],
29+
["/home/user/clean", { workspaces: [] }],
30+
],
31+
};
32+
fs.writeFileSync(configFile, JSON.stringify(corruptedConfig));
33+
34+
// Load config - should migrate paths
35+
const loaded = config.loadConfigOrDefault();
36+
37+
// Verify paths are normalized (no trailing slashes)
38+
const projectPaths = Array.from(loaded.projects.keys());
39+
expect(projectPaths).toContain("/home/user/project");
40+
expect(projectPaths).toContain("/home/user/another");
41+
expect(projectPaths).toContain("/home/user/clean");
42+
expect(projectPaths).not.toContain("/home/user/project/");
43+
expect(projectPaths).not.toContain("/home/user/another//");
44+
});
45+
});
46+
2147
describe("generateStableId", () => {
2248
it("should generate a 10-character hex string", () => {
2349
const id = config.generateStableId();

src/node/config.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
1111
import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility";
1212
import { getMuxHome } from "@/common/constants/paths";
1313
import { PlatformPaths } from "@/common/utils/paths";
14+
import { stripTrailingSlashes } from "@/node/utils/pathUtils";
1415

1516
// Re-export project types from dedicated types file (for preload usage)
1617
export type { Workspace, ProjectConfig, ProjectsConfig };
@@ -56,9 +57,13 @@ export class Config {
5657

5758
// Config is stored as array of [path, config] pairs
5859
if (parsed.projects && Array.isArray(parsed.projects)) {
59-
const projectsMap = new Map<string, ProjectConfig>(
60-
parsed.projects as Array<[string, ProjectConfig]>
61-
);
60+
const rawPairs = parsed.projects as Array<[string, ProjectConfig]>;
61+
// Migrate: normalize project paths by stripping trailing slashes
62+
// This fixes configs created with paths like "/home/user/project/"
63+
const normalizedPairs = rawPairs.map(([projectPath, projectConfig]) => {
64+
return [stripTrailingSlashes(projectPath), projectConfig] as [string, ProjectConfig];
65+
});
66+
const projectsMap = new Map<string, ProjectConfig>(normalizedPairs);
6267
return {
6368
projects: projectsMap,
6469
serverSshHost: parsed.serverSshHost,

src/node/utils/pathUtils.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,5 +129,23 @@ describe("pathUtils", () => {
129129
expect(result.valid).toBe(true);
130130
expect(result.expandedPath).toBe(tempDir);
131131
});
132+
133+
it("should strip trailing slashes from path", async () => {
134+
// Create .git directory for validation
135+
// eslint-disable-next-line local/no-sync-fs-methods -- Test setup only
136+
fs.mkdirSync(path.join(tempDir, ".git"));
137+
138+
// Test with single trailing slash
139+
const resultSingle = await validateProjectPath(`${tempDir}/`);
140+
expect(resultSingle.valid).toBe(true);
141+
expect(resultSingle.expandedPath).toBe(tempDir);
142+
expect(resultSingle.expandedPath).not.toMatch(/[/\\]$/);
143+
144+
// Test with multiple trailing slashes
145+
const resultMultiple = await validateProjectPath(`${tempDir}//`);
146+
expect(resultMultiple.valid).toBe(true);
147+
expect(resultMultiple.expandedPath).toBe(tempDir);
148+
expect(resultMultiple.expandedPath).not.toMatch(/[/\\]$/);
149+
});
132150
});
133151
});

src/node/utils/pathUtils.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ export function expandTilde(inputPath: string): string {
2626
return PlatformPaths.expandHome(inputPath);
2727
}
2828

29+
/**
30+
* Strip trailing slashes from a path.
31+
* path.normalize() preserves a single trailing slash which breaks basename extraction.
32+
*
33+
* @param inputPath - Path that may have trailing slashes
34+
* @returns Path without trailing slashes
35+
*
36+
* @example
37+
* stripTrailingSlashes("/home/user/project/") // => "/home/user/project"
38+
* stripTrailingSlashes("/home/user/project//") // => "/home/user/project"
39+
*/
40+
export function stripTrailingSlashes(inputPath: string): string {
41+
return inputPath.replace(/[/\\]+$/, "");
42+
}
43+
2944
/**
3045
* Validate that a project path exists, is a directory, and is a git repository
3146
* Automatically expands tilde and normalizes the path
@@ -47,8 +62,8 @@ export async function validateProjectPath(inputPath: string): Promise<PathValida
4762
// Expand tilde if present
4863
const expandedPath = expandTilde(inputPath);
4964

50-
// Normalize to resolve any .. or . in the path
51-
const normalizedPath = path.normalize(expandedPath);
65+
// Normalize to resolve any .. or . in the path, then strip trailing slashes
66+
const normalizedPath = stripTrailingSlashes(path.normalize(expandedPath));
5267

5368
// Check if path exists
5469
try {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { electronTest as test, electronExpect as expect } from "../electronTest";
2+
import fs from "fs";
3+
import path from "path";
4+
import { spawnSync } from "child_process";
5+
6+
test.skip(
7+
({ browserName }) => browserName !== "chromium",
8+
"Electron scenario runs on chromium only"
9+
);
10+
11+
test.describe("Project Path Handling", () => {
12+
test("project with trailing slash displays correctly", async ({ workspace, page }) => {
13+
const { configRoot } = workspace;
14+
const srcDir = path.join(configRoot, "src");
15+
const sessionsDir = path.join(configRoot, "sessions");
16+
17+
// Create a project path WITH trailing slash to simulate the bug
18+
const projectPathWithSlash = path.join(configRoot, "fixtures", "trailing-slash-project") + "/";
19+
const projectName = "trailing-slash-project"; // Expected extracted name
20+
const workspaceBranch = "test-branch";
21+
const workspacePath = path.join(srcDir, projectName, workspaceBranch);
22+
23+
// Create directories
24+
fs.mkdirSync(path.dirname(projectPathWithSlash), { recursive: true });
25+
fs.mkdirSync(projectPathWithSlash, { recursive: true });
26+
fs.mkdirSync(workspacePath, { recursive: true });
27+
fs.mkdirSync(sessionsDir, { recursive: true });
28+
29+
// Initialize git repos
30+
for (const repoPath of [projectPathWithSlash, workspacePath]) {
31+
spawnSync("git", ["init", "-q"], { cwd: repoPath });
32+
spawnSync("git", ["config", "user.email", "[email protected]"], { cwd: repoPath });
33+
spawnSync("git", ["config", "user.name", "Test"], { cwd: repoPath });
34+
spawnSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: repoPath });
35+
}
36+
37+
// Write config with trailing slash in project path - this tests the migration
38+
const configPayload = {
39+
projects: [[projectPathWithSlash, { workspaces: [{ path: workspacePath }] }]],
40+
};
41+
fs.writeFileSync(path.join(configRoot, "config.json"), JSON.stringify(configPayload, null, 2));
42+
43+
// Create workspace session with metadata
44+
const workspaceId = `${projectName}-${workspaceBranch}`;
45+
const workspaceSessionDir = path.join(sessionsDir, workspaceId);
46+
fs.mkdirSync(workspaceSessionDir, { recursive: true });
47+
fs.writeFileSync(
48+
path.join(workspaceSessionDir, "metadata.json"),
49+
JSON.stringify({
50+
id: workspaceId,
51+
name: workspaceBranch,
52+
projectName,
53+
projectPath: projectPathWithSlash,
54+
})
55+
);
56+
fs.writeFileSync(path.join(workspaceSessionDir, "chat.jsonl"), "");
57+
58+
// Reload the page to pick up the new config
59+
await page.reload();
60+
await page.waitForLoadState("domcontentloaded");
61+
62+
// Find the project in the sidebar - it should show the project name, not empty
63+
const navigation = page.getByRole("navigation", { name: "Projects" });
64+
await expect(navigation).toBeVisible();
65+
66+
// The project name should be visible (extracted correctly despite trailing slash)
67+
// If the bug was present, we'd see an empty project name or just "/"
68+
await expect(navigation.getByText(projectName)).toBeVisible();
69+
70+
// Verify the workspace is also visible under the project
71+
const projectItem = navigation.locator('[role="button"][aria-controls]').first();
72+
await expect(projectItem).toBeVisible();
73+
74+
// Expand to see workspace
75+
const expandButton = projectItem.getByRole("button", { name: /expand project/i });
76+
if (await expandButton.isVisible()) {
77+
await expandButton.click();
78+
}
79+
80+
// Workspace branch should be visible
81+
await expect(navigation.getByText(workspaceBranch)).toBeVisible();
82+
});
83+
});

0 commit comments

Comments
 (0)