diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 841b96f70e2a..9b155f92885e 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -6,6 +6,7 @@ import { buildProjectCreateCommand, findExistingAddProject, getAddProjectInitialQuery, + getCloneDestinationInitialQuery, resolveAddProjectPath, sortAddProjectProviderSources, type AddProjectRemoteSource, @@ -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(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; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf9844576..5c1208d71fe5 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -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, @@ -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 { @@ -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, @@ -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, diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index bf4e2c893924..1be684b230e5 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -12,6 +12,7 @@ import { buildProjectCreateCommand, findExistingAddProject, getAddProjectInitialQuery, + getCloneDestinationInitialQuery, resolveAddProjectPath, sortAddProjectProviderSources, } from "./projects.ts"; @@ -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({ diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index ec58418a94f2..b677dd277de5 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -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"; @@ -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; diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 80578e262f91..9d7a0bc193f0 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -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( diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index ae50b1488356..072684d02ddb 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -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, ""); + + 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. */