1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3import { EnvironmentId, ProjectId } from "@t3tools/contracts";
4
5import {
6 findExistingAddProject,
7 getCloneDestinationForPickedFolder,
8 getCloneDestinationPath,
9 getCloneDirectoryLeafName,
10 getCloneDirectoryName,
11 getCloneDirectoryPath,
12 getDefaultCloneUrl,
13 githubIncompleteOwnerRepositoryMessage,
14 normalizePastedCloneUrl,
15 sortAddProjectProviderSources,
16} from "./projects.ts";
17import type { EnvironmentProject } from "../state/models.ts";
18
19describe("add project shared logic", () => {
20 it("offers only GitHub until other hosts are turned on", () => {
21 const ready = () => ({ ready: true, hint: null });
22 expect(sortAddProjectProviderSources(ready, {})).toEqual(["github"]);
23 expect(sortAddProjectProviderSources(ready, { gitlab: true, github: false })).toEqual([
24 "gitlab",
25 ]);
26 });
27
28 it("asks for a repository name when GitHub input is only an owner path", () => {
29 const github = SourceControlProviderKind.make("github");
30 expect(githubIncompleteOwnerRepositoryMessage(github, "pingdotgg/")).toBe(
31 "Enter the full owner/repository name.",
32 );
33 expect(githubIncompleteOwnerRepositoryMessage(github, " pingdotgg// ")).toBe(
34 "Enter the full owner/repository name.",
35 );
36 expect(githubIncompleteOwnerRepositoryMessage(github, "mona-cat_octo/")).toBe(
37 "Enter the full owner/repository name.",
38 );
39
40 for (const input of [
41 "pingdotgg/t3code",
42 "mona-cat_octo/repo",
43 "pingdotgg",
44 "https://github.com/pingdotgg/t3code.git",
45 "git@github.com:pingdotgg/t3code.git",
46 ]) {
47 expect(githubIncompleteOwnerRepositoryMessage(github, input)).toBeNull();
48 }
49 expect(
50 githubIncompleteOwnerRepositoryMessage(SourceControlProviderKind.make("gitlab"), "group/"),
51 ).toBeNull();
52 expect(githubIncompleteOwnerRepositoryMessage("url", "owner/")).toBeNull();
53 expect(
54 githubIncompleteOwnerRepositoryMessage(SourceControlProviderKind.make("forgejo"), "owner/"),
55 ).toBeNull();
56 });
57
58 it("routes owner/repository shorthand to GitHub over HTTPS", () => {
59 expect(normalizePastedCloneUrl("imputnet/helium")).toBe(
60 "https://github.com/imputnet/helium.git",
61 );
62 expect(normalizePastedCloneUrl(" pingdotgg/t3code ")).toBe(
63 "https://github.com/pingdotgg/t3code.git",
64 );
65 });
66
67 it("keeps explicit clone URLs and local paths unchanged", () => {
68 expect(normalizePastedCloneUrl("https://gitlab.com/group/project.git")).toBe(
69 "https://gitlab.com/group/project.git",
70 );
71 expect(normalizePastedCloneUrl("git@github.com:owner/repo.git")).toBe(
72 "git@github.com:owner/repo.git",
73 );
74 expect(normalizePastedCloneUrl("group/subgroup/project")).toBe("group/subgroup/project");
75 expect(normalizePastedCloneUrl("/srv/git/repo.git")).toBe("/srv/git/repo.git");
76 for (const gitlabProject of [
77 "https://gitlab.com/acme/tools/src/branch/worker.git",
78 "https://scm.example.test/acme/tools/src/branch/worker.git",
79 "https://scm.example.test/acme/tools/src/branch/worker",
80 "https://scm.example.test/team/repo/src/branch/main/-/README.md",
81 "https://forgejo.example.test/a/b/src/branch/mount/team/repo.git",
82 ]) {
83 expect(normalizePastedCloneUrl(gitlabProject)).toBe(gitlabProject);
84 }
85 });
86
87 it("clones the repository when given a page URL copied from the browser", () => {
88 for (const [pageUrl, pageRoute, repository] of [
89 ["https://github.com/pingdotgg/t3code/tree/main", "/tree/", "t3code"],
90 ["https://gitlab.com/gitlab-org/gitlab-runner/-/tree/main", "/-/", "gitlab-runner"],
91 ["https://git.cafe/pingdotgg/t3code/tree/main", "/tree/", "t3code"],
92 ["https://gitlab.example.test/g/p/-/blob/main/src/branch/x", "/-/", "p"],
93 ["https://code.company.test/g/p/-/tree/main", "/-/", "p"],
94 ["https://github.com/pingdotgg/t3code/tree/release.git", "/tree/", "t3code"],
95 ] as const) {
96 const cloneUrl = normalizePastedCloneUrl(pageUrl);
97 expect(cloneUrl).not.toContain(pageRoute);
98 expect(getCloneDirectoryName(cloneUrl)).toBe(repository);
99 }
100 expect(
101 normalizePastedCloneUrl("https://alice:example@bitbucket.org/workspace/repo/src/master/"),
102 ).toBe("https://alice:example@bitbucket.org/workspace/repo");
103 });
104
105 it("uses HTTPS for repositories selected through a provider", () => {
106 expect(
107 getDefaultCloneUrl({
108 provider: SourceControlProviderKind.make("github"),
109 url: "https://github.com/imputnet/helium",
110 sshUrl: "git@github.com:imputnet/helium.git",
111 }),
112 ).toBe("https://github.com/imputnet/helium");
113 expect(
114 getDefaultCloneUrl({
115 provider: SourceControlProviderKind.make("forgejo"),
116 url: "https://forgejo.example.test:8443/owner/repo.git",
117 sshUrl: "ssh://git@forgejo.example.test:2222/owner/repo.git",
118 }),
119 ).toBe("https://forgejo.example.test:8443/owner/repo.git");
120 });
121
122 it("derives the clone folder name from any pasted clone URL", () => {
123 expect(getCloneDirectoryName("https://github.com/owner/repo.git")).toBe("repo");
124 expect(getCloneDirectoryName("https://github.com/owner/repo")).toBe("repo");
125 expect(getCloneDirectoryName("https://github.com/owner/repo/")).toBe("repo");
126 expect(getCloneDirectoryName("git@github.com:owner/repo.git")).toBe("repo");
127 expect(getCloneDirectoryName("ssh://git@github.com:22/owner/repo.git")).toBe("repo");
128 expect(getCloneDirectoryName("https://user@bitbucket.org/owner/repo.git")).toBe("repo");
129 expect(getCloneDirectoryName("https://dev.azure.com/org/project/_git/repo")).toBe("repo");
130 expect(getCloneDirectoryName("https://github.com/owner/repo.git?ref=main#readme")).toBe("repo");
131 expect(getCloneDirectoryName("/srv/git/repo.git")).toBe("repo");
132 expect(getCloneDirectoryName("C:\\src\\repo.git")).toBe("repo");
133 expect(getCloneDirectoryName("git@github.com:repo.git")).toBe("repo");
134 expect(getCloneDirectoryName(" https://github.com/owner/repo.git ")).toBe("repo");
135 });
136
137 it("lays clone folders out like ghq only when asked, and only when the URL is safe", () => {
138 expect(getCloneDirectoryPath("git@github.com:owner/repo.git", false)).toBe("repo");
139 expect(getCloneDirectoryPath("git@github.com:owner/repo.git", true)).toBe(
140 "github.com/owner/repo",
141 );
142 expect(getCloneDirectoryPath("ssh://git@gitlab.com:2222/group/sub/repo.git", true)).toBe(
143 "gitlab.com/group/sub/repo",
144 );
145 expect(getCloneDirectoryPath("https://github.com/owner/../../repo", true)).toBe("repo");
146 expect(getCloneDirectoryPath("/srv/git/repo.git", true)).toBe("repo");
147 expect(
148 getCloneDirectoryPath(
149 "git@ssh.dev.azure.com:v3/org/Project/My%20Repo",
150 false,
151 "Project/My Repo",
152 ),
153 ).toBe("My Repo");
154 expect(
155 getCloneDirectoryLeafName(
156 "git@ssh.dev.azure.com:v3/org/Project/My%20Repo",
157 true,
158 "Project/My Repo",
159 ),
160 ).toBe("My%20Repo");
161 expect(
162 getCloneDestinationPath(
163 "C:\\Users\\me\\ghq",
164 getCloneDirectoryPath("git@github.com:owner/repo.git", true),
165 ),
166 ).toBe("C:\\Users\\me\\ghq\\github.com\\owner\\repo");
167 });
168
169 it("keeps a numeric repository name that sits on a path", () => {
170 expect(getCloneDirectoryName("https://github.com/acme/123.git")).toBe("123");
171 expect(getCloneDirectoryName("https://github.com/acme/123")).toBe("123");
172 expect(getCloneDirectoryName("git@github.com:acme/123.git")).toBe("123");
173 });
174
175 it("resolves a native-picker folder like an in-app clone selection", () => {
176 const pick = (pickedPath: string, caseSensitive = true) =>
177 getCloneDestinationForPickedFolder({
178 pickedPath,
179 cloneDirectoryName: "repo",
180 caseSensitive,
181 });
182
183 expect(pick("/Users/me/code")).toBe("/Users/me/code/repo");
184 expect(pick("/Users/me/code/")).toBe("/Users/me/code/repo");
185 expect(pick("/Users/me/repo")).toBe("/Users/me/repo/");
186 expect(pick("/Users/me/Repo")).toBe("/Users/me/Repo/repo");
187 expect(pick("C:\\Projects\\Repo", false)).toBe("C:\\Projects\\Repo\\");
188 expect(pick("C:\\Projects", false)).toBe("C:\\Projects\\repo");
189 expect(pick("/")).toBe("/repo");
190 });
191
192 it("finds existing projects by normalized path in the target environment", () => {
193 const env = EnvironmentId.make("env");
194 const other = EnvironmentId.make("other");
195 const projects: EnvironmentProject[] = [
196 {
197 environmentId: other,
198 id: ProjectId.make("same-path-other-env"),
199 title: "Other",
200 workspaceRoot: "/repo",
201 createdAt: "2026-01-01T00:00:00.000Z",
202 updatedAt: "2026-01-01T00:00:00.000Z",
203 repositoryIdentity: null,
204 defaultModelSelection: null,
205 scripts: [],
206 },
207 {
208 environmentId: env,
209 id: ProjectId.make("project"),
210 title: "Repo",
211 workspaceRoot: "/repo/",
212 createdAt: "2026-01-01T00:00:00.000Z",
213 updatedAt: "2026-01-01T00:00:00.000Z",
214 repositoryIdentity: null,
215 defaultModelSelection: null,
216 scripts: [],
217 },
218 ];
219
220 expect(findExistingAddProject({ projects, environmentId: env, path: "/repo" })?.id).toBe(
221 "project",
222 );
223 });
224});