fix: handle Windows drive-letter paths across the codebase (#148)

* Add metrics collection and terminal performance tests

* fix: handle Windows drive-letter paths across the codebase

Windows paths like C:\Users\foo\project were broken in multiple places:
- agent-storage slugified D:\MyProject as D:-MyProject (illegal colon)
- terminal-manager rejected all non-/ paths as relative
- bootstrap parser misparsed drive colons as TCP host:port
- daemon/client connection helpers misclassified Windows paths
- CLI cwd filtering used hardcoded / separators
- checkout-git worktree detection used hardcoded / in path checks
- worktree archive used split("/").pop() instead of path.basename()

All path helpers now normalize separators and handle Windows
drive letters with case-insensitive comparison where needed.
This commit is contained in:
Mohamed Boudra
2026-03-27 18:17:48 +07:00
committed by GitHub
parent c667aca3e0
commit ca443b3ecf
25 changed files with 980 additions and 49 deletions
@@ -18,6 +18,9 @@ import {
NotGitRepoError,
pushCurrentBranch,
resolveRepositoryDefaultBranch,
parseWorktreeList,
isPaseoWorktreePath,
isDescendantPath,
} from "./checkout-git.js";
import { createWorktree } from "./worktree.js";
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
@@ -812,4 +815,73 @@ const x = 1;
).rejects.toThrow(/base/i);
await expect(mergeToBase(worktree.worktreePath, {}, { paseoHome })).rejects.toThrow(/base/i);
});
describe("parseWorktreeList", () => {
it("parses porcelain worktree output", () => {
const output = [
"worktree /home/user/repo",
"branch refs/heads/main",
"",
"worktree /home/user/.paseo/worktrees/feature",
"branch refs/heads/feature",
"",
].join("\n");
const entries = parseWorktreeList(output);
expect(entries).toHaveLength(2);
expect(entries[0]).toEqual({ path: "/home/user/repo", branchRef: "refs/heads/main" });
expect(entries[1]).toEqual({
path: "/home/user/.paseo/worktrees/feature",
branchRef: "refs/heads/feature",
});
});
it("detects bare repos", () => {
const output = ["worktree /home/user/repo.git", "bare", ""].join("\n");
const entries = parseWorktreeList(output);
expect(entries).toHaveLength(1);
expect(entries[0]?.isBare).toBe(true);
});
});
describe("isPaseoWorktreePath", () => {
it("matches Unix .paseo/worktrees/ paths", () => {
expect(isPaseoWorktreePath("/home/user/.paseo/worktrees/feature")).toBe(true);
});
it("matches Windows .paseo\\worktrees\\ paths", () => {
expect(isPaseoWorktreePath("C:\\Users\\dev\\.paseo\\worktrees\\feature")).toBe(true);
});
it("rejects paths without .paseo/worktrees segment", () => {
expect(isPaseoWorktreePath("/home/user/repo")).toBe(false);
expect(isPaseoWorktreePath("C:\\Users\\dev\\repo")).toBe(false);
});
});
describe("isDescendantPath", () => {
it("detects children with Unix separators", () => {
expect(isDescendantPath("/home/user/repo/child", "/home/user/repo")).toBe(true);
});
it("detects children with Windows separators", () => {
expect(isDescendantPath("C:\\repos\\child", "C:\\repos")).toBe(true);
});
it("rejects the parent itself", () => {
expect(isDescendantPath("/home/user/repo", "/home/user/repo")).toBe(false);
});
it("rejects siblings that share a prefix", () => {
expect(isDescendantPath("/home/user/repo-extra", "/home/user/repo")).toBe(false);
});
it("handles mixed separators", () => {
expect(isDescendantPath("C:/repo/child", "C:\\repo")).toBe(true);
});
it("is case insensitive on Windows paths", () => {
expect(isDescendantPath("c:\\repo\\child", "C:\\repo")).toBe(true);
});
});
});
+23 -4
View File
@@ -603,20 +603,39 @@ async function getMainRepoRoot(cwd: string): Promise<string> {
});
const worktrees = parseWorktreeList(worktreeOut);
const nonBareNonPaseo = worktrees.filter(
(wt) => !wt.isBare && !wt.path.includes("/.paseo/worktrees/"),
(wt) => !wt.isBare && !isPaseoWorktreePath(wt.path),
);
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => wt.path.startsWith(normalized + "/"));
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => isDescendantPath(wt.path, normalized));
const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main");
return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized;
}
type GitWorktreeEntry = {
export type GitWorktreeEntry = {
path: string;
branchRef?: string;
isBare?: boolean;
};
function parseWorktreeList(output: string): GitWorktreeEntry[] {
/** Check whether a path contains a `.paseo/worktrees/` segment (both `/` and `\`). */
export function isPaseoWorktreePath(p: string): boolean {
return /[/\\]\.paseo[/\\]worktrees[/\\]/.test(p);
}
/** True when `child` is strictly inside `parent` (handles both `/` and `\`). */
export function isDescendantPath(child: string, parent: string): boolean {
let c = child.replace(/\\/g, "/").replace(/\/+$/, "");
let p = parent.replace(/\\/g, "/").replace(/\/+$/, "");
// Case-insensitive on Windows (drive letter like C: or D:)
if (/^[A-Za-z]:/.test(c) || /^[A-Za-z]:/.test(p)) {
c = c.toLowerCase();
p = p.toLowerCase();
}
if (!c.startsWith(p)) return false;
if (c.length === p.length) return false;
return c[p.length] === "/";
}
export function parseWorktreeList(output: string): GitWorktreeEntry[] {
const entries: GitWorktreeEntry[] = [];
let current: GitWorktreeEntry | null = null;
for (const line of output.split("\n")) {