Skip to content
Closed
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
7 changes: 4 additions & 3 deletions apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildProjectCreateCommand,
findExistingAddProject,
getAddProjectInitialQuery,
getCloneDestinationInitialQuery,
resolveAddProjectPath,
sortAddProjectProviderSources,
type AddProjectRemoteSource,
Expand Down Expand Up @@ -757,15 +758,15 @@ export function AddProjectDestinationScreen(props: {
const remoteUrl = stringParam(props.remoteUrl);
const repositoryTitle = stringParam(props.repositoryTitle);
const [pathInput, setPathInput] = useState(() =>
getAddProjectInitialQuery(environment?.baseDirectory),
getCloneDestinationInitialQuery(environment?.baseDirectory, remoteUrl),
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!environment) return;
setPathInput(getAddProjectInitialQuery(environment.baseDirectory));
}, [environment]);
setPathInput(getCloneDestinationInitialQuery(environment.baseDirectory, remoteUrl));
}, [environment, remoteUrl]);

const submitPath = useCallback(async () => {
if (!environment || !remoteUrl || isSubmitting) return;
Expand Down
18 changes: 14 additions & 4 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { getCloneDestinationInitialQuery } from "@t3tools/client-runtime/operations/projects";
import {
isAtomCommandInterrupted,
settlePromise,
Expand Down Expand Up @@ -1221,8 +1222,11 @@ function OpenCommandPaletteDialog(props: {
],
);

function getDefaultCloneParentPath(environmentId: EnvironmentId): string {
return getAddProjectInitialQueryForEnvironment(environmentId);
function getDefaultCloneDestinationPath(environmentId: EnvironmentId, remoteUrl: string): string {
return getCloneDestinationInitialQuery(
getAddProjectInitialQueryForEnvironment(environmentId),
remoteUrl,
);
}

async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise<void> {
Expand All @@ -1238,7 +1242,10 @@ function OpenCommandPaletteDialog(props: {

const provider = remoteProjectSourceProvider(addProjectCloneFlow.source);
if (!provider) {
const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId);
const destinationPath = getDefaultCloneDestinationPath(
addProjectCloneFlow.environmentId,
rawRepository,
);
setAddProjectCloneFlow({
step: "confirm",
environmentId: addProjectCloneFlow.environmentId,
Expand Down Expand Up @@ -1275,7 +1282,10 @@ function OpenCommandPaletteDialog(props: {
return;
}
const repository = lookupResult.value;
const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId);
const destinationPath = getDefaultCloneDestinationPath(
addProjectCloneFlow.environmentId,
repository.sshUrl,
);
setAddProjectCloneFlow({
step: "confirm",
environmentId: addProjectCloneFlow.environmentId,
Expand Down
19 changes: 19 additions & 0 deletions packages/client-runtime/src/operations/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
buildProjectCreateCommand,
findExistingAddProject,
getAddProjectInitialQuery,
getCloneDestinationInitialQuery,
resolveAddProjectPath,
sortAddProjectProviderSources,
} from "./projects.ts";
Expand All @@ -24,6 +25,24 @@ describe("add project shared logic", () => {
expect(getAddProjectInitialQuery("C:\\work")).toBe("C:\\work\\");
});

it("initializes clone destinations with the inferred repository directory", () => {
expect(
getCloneDestinationInitialQuery("/work/projects", "git@github.com:openai/codex.git"),
).toBe("/work/projects/codex");
expect(getCloneDestinationInitialQuery("C:\\work", "https://github.com/openai/codex.git")).toBe(
"C:\\work\\codex",
);
expect(getCloneDestinationInitialQuery(null, "https://github.com/openai/codex.git")).toBe(
"~/codex",
);
});

it("falls back to the clone base directory when the remote has no repository name", () => {
expect(getCloneDestinationInitialQuery("/work/projects", "https://github.com")).toBe(
"/work/projects/",
);
});

it("rejects unsupported windows paths on non-windows environments", () => {
expect(
resolveAddProjectPath({
Expand Down
10 changes: 10 additions & 0 deletions packages/client-runtime/src/operations/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
SourceControlRepositoryInfo,
} from "@t3tools/contracts";
import { DEFAULT_MODEL, ProviderInstanceId } from "@t3tools/contracts";
import { inferGitCloneDirectoryName } from "@t3tools/shared/git";
import * as Arr from "effect/Array";
import * as Option from "effect/Option";
import * as Order from "effect/Order";
Expand Down Expand Up @@ -170,6 +171,15 @@ export function getAddProjectInitialQuery(baseDirectory: string | null | undefin
return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed);
}

export function getCloneDestinationInitialQuery(
baseDirectory: string | null | undefined,
remoteUrl: string | null | undefined,
): string {
const basePath = getAddProjectInitialQuery(baseDirectory);
const directoryName = inferGitCloneDirectoryName(remoteUrl ?? "");
return directoryName ? `${basePath}${directoryName}` : basePath;
}

export function resolveAddProjectPath(input: {
readonly rawPath: string;
readonly currentProjectCwd?: string | null;
Expand Down
29 changes: 29 additions & 0 deletions packages/shared/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,41 @@ import { describe, expect, it } from "vite-plus/test";
import {
applyGitStatusStreamEvent,
buildTemporaryWorktreeBranchName,
inferGitCloneDirectoryName,
isTemporaryWorktreeBranch,
normalizeGitRemoteUrl,
parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
WORKTREE_BRANCH_PREFIX,
} from "./git.ts";

describe("inferGitCloneDirectoryName", () => {
it("infers checkout names from common remote URL shapes", () => {
expect(inferGitCloneDirectoryName("https://github.com/openai/codex.git")).toBe("codex");
expect(inferGitCloneDirectoryName("org-14957082@github.com:openai/codex.git")).toBe("codex");
expect(inferGitCloneDirectoryName("ssh://git@gitlab.com/group/nested/project.git")).toBe(
"project",
);
expect(inferGitCloneDirectoryName("https://dev.azure.com/acme/team/_git/platform")).toBe(
"platform",
);
});

it("handles escaped names and Git's special suffixes", () => {
expect(inferGitCloneDirectoryName("https://example.com/acme/my%20project.git?ref=main")).toBe(
"my project",
);
expect(inferGitCloneDirectoryName("/srv/git/project.bundle")).toBe("project");
expect(inferGitCloneDirectoryName("https://example.com/acme/project/.git/")).toBe("project");
});

it("returns null when a checkout name cannot be inferred", () => {
expect(inferGitCloneDirectoryName(" ")).toBeNull();
expect(inferGitCloneDirectoryName("https://github.com")).toBeNull();
expect(inferGitCloneDirectoryName("git@github.com:")).toBeNull();
expect(inferGitCloneDirectoryName("https://example.com/acme/project%2Fnested.git")).toBeNull();
});
});

describe("normalizeGitRemoteUrl", () => {
it("canonicalizes equivalent GitHub remotes across protocol variants", () => {
expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe(
Expand Down
44 changes: 44 additions & 0 deletions packages/shared/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,50 @@ export function normalizeGitRemoteUrl(value: string): string {
return normalized;
}

/**
* Infer the checkout directory Git would normally derive from a clone URL.
* Supports URL-shaped, SCP-style, and local-path remotes.
*/
export function inferGitCloneDirectoryName(remoteUrl: string): string | null {
const trimmed = remoteUrl.trim();
if (trimmed.length === 0) {
return null;
}

let repositoryPath = trimmed;
if (/^(?:ssh|https?|git|file):\/\//i.test(trimmed)) {
try {
repositoryPath = new URL(trimmed).pathname;
} catch {
return null;
}
}

const withoutTrailingSeparators = repositoryPath.replace(/[\\/]+$/g, "");
const withoutDotGitDirectory = withoutTrailingSeparators.replace(/[\\/]\.git$/i, "");
const lastSeparatorIndex = Math.max(
withoutDotGitDirectory.lastIndexOf("/"),
withoutDotGitDirectory.lastIndexOf("\\"),
withoutDotGitDirectory.lastIndexOf(":"),
);
const encodedName = withoutDotGitDirectory
.slice(lastSeparatorIndex + 1)
.replace(/\.(?:git|bundle)$/i, "");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Colon breaks checkout name inference

Low Severity

inferGitCloneDirectoryName treats the last : in the string like a path separator when picking the final segment. SCP remotes only use the first colon after the host; extra colons in the repo path (without /) and local paths whose final component contains : can yield a shorter wrong default checkout name in getCloneDestinationInitialQuery.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 32dbcbc. Configure here.


if (encodedName.length === 0) {
return null;
}

let name = encodedName;
try {
name = decodeURIComponent(encodedName);
} catch {
// Keep the original segment when the remote contains malformed URL escapes.
}

return name === "." || name === ".." || /[\\/]/.test(name) ? null : name;
}

/**
* Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes.
*/
Expand Down
Loading