authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-07 03:48:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 03:48:14-07:00
log8b2838e0e8a73d3fa6476940445c372e47b99db4
tree3a5abcd1abc0c6b754859c0acf052fc13e0eda75
parentdc39615aec702ea6d402168f80b4d1613f4f2e0f
signature Signed by PGP key B5690EEEBB952194

feat(web): group onboarding project import by repository (#10493)

Onboarding listed every directory Claude Code or Codex had ever run in as one flat list of paths, with everything from the last 30 days preselected. On my machine that was 270 rows and 80 preselected. Most of them were Codex scratch folders, worktrees, and one-off questions. I wanted two or three projects and had no fast way to get there. The scanner now reads each candidate's `.git/config` directly, so the client can group clones by origin and show the GitHub `owner/name`. Linked worktrees, Codex scratch directories under `~/Documents/Codex`, `~/Downloads`, and temp roots are no longer offered. Folders that are not git repositories collapse under "Other folders". The default selection requires a git repository with at least three threads. Select all and Select none sit above the list, and each row shows the source icons, thread count, and last activity. On the same machine this drops the list to 162 rows and the default selection to 16. Mobile has no project import step, so there is no mobile change. Created with Claude Fable 5.1 in Claude Code.

9 files changed, 764 insertions(+), 49 deletions(-)

apps/server/src/project/AgentSessionScanner.test.ts+118
......@@ -213,6 +213,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
213213 threadCount: 1,
214214 lastActiveAt: "2026-03-01T00:00:00.000Z",
215215 alreadyImported: false,
216 git: null,
216217 },
217218 {
218219 path: olderWorkspace,
......@@ -221,6 +222,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
221222 threadCount: 2,
222223 lastActiveAt: "2026-01-02T00:00:00.000Z",
223224 alreadyImported: false,
225 git: null,
224226 },
225227 ]);
226228 }),
......@@ -263,6 +265,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
263265 threadCount: 1,
264266 lastActiveAt: "2026-02-09T11:00:00.000Z",
265267 alreadyImported: false,
268 git: null,
266269 },
267270 {
268271 path: workspace,
......@@ -271,6 +274,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
271274 threadCount: 2,
272275 lastActiveAt: "2026-02-09T10:00:00.000Z",
273276 alreadyImported: false,
277 git: null,
274278 },
275279 ]);
276280 }),
......@@ -389,6 +393,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
389393 threadCount: 2,
390394 lastActiveAt: "2026-04-01T09:00:00.000Z",
391395 alreadyImported: true,
396 git: null,
392397 },
393398 ]);
394399 }),
......@@ -421,6 +426,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
421426 path: workspace,
422427 projectId: ProjectId.make("project-1"),
423428 alreadyImported: true,
429 git: null,
424430 });
425431 }),
426432 );
......@@ -452,6 +458,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
452458 path: workspaceAlias,
453459 projectId: ProjectId.make("project-1"),
454460 alreadyImported: true,
461 git: null,
455462 });
456463 }),
457464 );
......@@ -498,6 +505,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
498505 threadCount: 2,
499506 lastActiveAt: "2026-01-02T00:00:00.000Z",
500507 alreadyImported: true,
508 git: null,
501509 },
502510 ]);
503511 }),
......@@ -855,6 +863,115 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
855863 }),
856864 );
857865
866 it.effect("excludes Codex scratch directories and Downloads", () =>
867 Effect.gen(function* () {
868 const path = yield* Path.Path;
869 const fileSystem = yield* FileSystem.FileSystem;
870 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
871 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
872 // The exclusions key off the real home directory, so these fixtures
873 // must live there. Each run owns a uniquely named subtree and removes
874 // only that subtree, never the shared Codex or Downloads parents.
875 const home = NodeOS.homedir();
876 // Borrow a unique suffix from a scoped temp dir instead of reaching for
877 // Date.now or Math.random, which the Effect lint rejects.
878 const runId = path.basename(yield* makeTempDir("t3code-scanner-test-"));
879 const scratchRoot = path.join(home, "Documents", "Codex", runId);
880 const scratch = path.join(scratchRoot, "2026-09-01", "some-conversation");
881 const downloads = path.join(home, "Downloads", runId);
882 const keep = yield* makeTempDir("t3code-workspace-keep-");
883 yield* fileSystem.makeDirectory(scratch, { recursive: true });
884 yield* fileSystem.makeDirectory(downloads, { recursive: true });
885 yield* Effect.addFinalizer(() =>
886 Effect.all([
887 fileSystem.remove(scratchRoot, { recursive: true }).pipe(Effect.ignore),
888 fileSystem.remove(downloads, { recursive: true }).pipe(Effect.ignore),
889 ]),
890 );
891
892 for (const [index, cwd] of [scratch, downloads, keep].entries()) {
893 yield* writeTranscript({
894 filePath: path.join(
895 codexHomePath,
896 "sessions",
897 "2026",
898 "09",
899 "01",
900 `rollout-${index}.jsonl`,
901 ),
902 contents: codexRolloutLine(cwd),
903 mtimeMs: Date.parse("2026-09-01T00:00:00.000Z"),
904 });
905 }
906
907 const result = yield* runScan({ claudeHomePath, codexHomePath });
908
909 expect(result.candidates.map((candidate) => candidate.path)).toEqual([keep]);
910 }),
911 );
912
913 it.effect("skips linked git worktrees and reports the origin of real checkouts", () =>
914 Effect.gen(function* () {
915 const path = yield* Path.Path;
916 const fileSystem = yield* FileSystem.FileSystem;
917 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
918 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
919 const repo = yield* makeTempDir("t3code-workspace-repo-");
920 const worktree = yield* makeTempDir("t3code-workspace-worktree-");
921 const plain = yield* makeTempDir("t3code-workspace-plain-");
922 const noRemote = yield* makeTempDir("t3code-workspace-noremote-");
923 const submodule = yield* makeTempDir("t3code-workspace-submodule-");
924
925 yield* fileSystem.makeDirectory(path.join(repo, ".git"));
926 yield* fileSystem.writeFileString(
927 path.join(repo, ".git", "config"),
928 '[core]\n\tbare = false\n[remote "origin"]\n\turl = git@github.com:pingdotgg/t3code.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n',
929 );
930 yield* fileSystem.writeFileString(
931 path.join(worktree, ".git"),
932 `gitdir: ${path.join(repo, ".git", "worktrees", "wt")}\n`,
933 );
934 yield* fileSystem.makeDirectory(path.join(noRemote, ".git"));
935 yield* fileSystem.writeFileString(path.join(noRemote, ".git", "config"), "[core]\n");
936 // Submodules also use a gitdir pointer, but into `modules/`, not `worktrees/`.
937 const submoduleGitDir = path.join(repo, ".git", "modules", "vendor");
938 yield* fileSystem.makeDirectory(submoduleGitDir, { recursive: true });
939 yield* fileSystem.writeFileString(
940 path.join(submoduleGitDir, "config"),
941 '[remote "origin"]\n\turl = ssh://github.com/pingdotgg/vendor.git\n',
942 );
943 yield* fileSystem.writeFileString(
944 path.join(submodule, ".git"),
945 `gitdir: ${submoduleGitDir}\n`,
946 );
947
948 for (const [index, cwd] of [repo, worktree, plain, noRemote, submodule].entries()) {
949 yield* writeTranscript({
950 filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"),
951 contents: claudeSessionLine(cwd),
952 mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`),
953 });
954 }
955
956 const result = yield* runScan({ claudeHomePath, codexHomePath });
957
958 expect(
959 result.candidates.map((candidate) => ({ path: candidate.path, git: candidate.git })),
960 ).toEqual([
961 {
962 path: submodule,
963 git: { remoteKey: "github.com/pingdotgg/vendor", repository: "pingdotgg/vendor" },
964 },
965 { path: noRemote, git: { remoteKey: null, repository: null } },
966 { path: plain, git: null },
967 {
968 path: repo,
969 git: { remoteKey: "github.com/pingdotgg/t3code", repository: "pingdotgg/t3code" },
970 },
971 ]);
972 }),
973 );
974
858975 it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () =>
859976 Effect.gen(function* () {
860977 const path = yield* Path.Path;
......@@ -1231,6 +1348,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
12311348 threadCount: 1,
12321349 lastActiveAt: "2026-05-03T00:00:00.000Z",
12331350 alreadyImported: false,
1351 git: null,
12341352 },
12351353 ]);
12361354 }),
apps/server/src/project/AgentSessionScanner.ts+73-2
......@@ -38,6 +38,11 @@ import * as Schema from "effect/Schema";
3838import * as Semaphore from "effect/Semaphore";
3939import * as Stream from "effect/Stream";
4040
41import {
42 normalizeGitRemoteUrl,
43 parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
44 parseOriginUrlFromGitConfig,
45} from "@t3tools/shared/git";
4146import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4247import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
4348
......@@ -624,14 +629,28 @@ export const make = Effect.gen(function* () {
624629 // must case fold.
625630 const foldWorktreeCase = (yield* HostProcessPlatform) === "win32";
626631 const hostEnvironment = yield* HostProcessEnvironment;
632 const homeDir = NodeOS.homedir();
633 // `/private/tmp` is what macOS reports for sessions started in `/tmp`.
627634 const excludedProjectRoots = new Set(
628 [NodeOS.homedir(), NodeOS.tmpdir()].map((directory) =>
635 [homeDir, NodeOS.tmpdir(), "/tmp", "/private/tmp"].map((directory) =>
629636 normalizeProjectPathForComparison(path.resolve(directory)),
630637 ),
631638 );
639 // Codex creates one scratch directory per conversation under
640 // ~/Documents/Codex/<date>/<slug>. Neither those nor anything a user
641 // unpacked into Downloads is a project.
642 const excludedProjectAncestors = [
643 path.join(homeDir, "Downloads"),
644 path.join(homeDir, "Documents", "Codex"),
645 ];
632646
633647 const isExcludedProjectPath = (candidatePath: string) =>
634648 excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) ||
649 excludedProjectAncestors.some((ancestor) =>
650 normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith(
651 normalizeForWorktreeMatch(ancestor, foldWorktreeCase),
652 ),
653 ) ||
635654 normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith(
636655 normalizeForWorktreeMatch(baseDir, foldWorktreeCase),
637656 ) ||
......@@ -664,6 +683,48 @@ export const make = Effect.gen(function* () {
664683 return `path:${normalizeProjectPathForComparison(realPath)}`;
665684 });
666685
686 /**
687 * Git identity of a directory, or the reason it has none. Reads `.git`
688 * directly instead of spawning git so a scan over hundreds of candidates
689 * stays cheap. A `.git` file is a `gitdir:` pointer. When it points into a
690 * `worktrees/` directory the checkout is a linked worktree, which
691 * onboarding skips because its history belongs to the main checkout.
692 * Submodules use the same pointer shape but live under `modules/`, and
693 * are offered like any other repository.
694 */
695 const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* (
696 directory: string,
697 ): Effect.fn.Return<
698 | { readonly _tag: "Repository"; readonly git: AgentSessionProjectCandidate["git"] }
699 | { readonly _tag: "Worktree" }
700 | { readonly _tag: "NotGit" }
701 > {
702 const gitPath = path.join(directory, ".git");
703 const gitStats = yield* statOption(gitPath);
704 if (Option.isNone(gitStats)) return { _tag: "NotGit" } as const;
705 let gitDir = gitPath;
706 if (gitStats.value.type !== "Directory") {
707 const pointer = yield* fileSystem
708 .readFileString(gitPath)
709 .pipe(Effect.orElseSucceed(() => ""));
710 const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim();
711 if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const;
712 gitDir = path.resolve(directory, target);
713 if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) return { _tag: "Worktree" } as const;
714 }
715 const configText = yield* fileSystem
716 .readFileString(path.join(gitDir, "config"))
717 .pipe(Effect.orElseSucceed(() => ""));
718 const originUrl = parseOriginUrlFromGitConfig(configText);
719 return {
720 _tag: "Repository",
721 git: {
722 remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl),
723 repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl),
724 },
725 } as const;
726 });
727
667728 // A large history snapshot can precede session metadata. Read bounded
668729 // chunks until a complete record names its cwd or the safety budget ends.
669730 const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* (
......@@ -1148,9 +1209,11 @@ export const make = Effect.gen(function* () {
11481209 sources: Array<AgentSessionSource>;
11491210 threadCount: number;
11501211 lastActiveAtMs: number | null;
1212 git: AgentSessionProjectCandidate["git"];
11511213 }
11521214 >();
11531215 const directoryKeys = new Map<string, string>();
1216 const gitIdentities = new Map<string, AgentSessionProjectCandidate["git"]>();
11541217
11551218 for (const candidate of raw) {
11561219 const expanded = expandHomePath(candidate.cwd.trim());
......@@ -1173,7 +1236,13 @@ export const make = Effect.gen(function* () {
11731236 if (isExcludedProjectPath(realPath)) {
11741237 key = "";
11751238 } else {
1176 key = yield* directoryIdentity(resolved, stats.value);
1239 const gitIdentity = yield* readGitIdentity(resolved);
1240 if (gitIdentity._tag === "Worktree") {
1241 key = "";
1242 } else {
1243 key = yield* directoryIdentity(resolved, stats.value);
1244 gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null);
1245 }
11771246 }
11781247 directoryKeys.set(resolved, key);
11791248 }
......@@ -1186,6 +1255,7 @@ export const make = Effect.gen(function* () {
11861255 sources: [candidate.source],
11871256 threadCount: candidate.threadCount,
11881257 lastActiveAtMs: candidate.lastActiveAtMs,
1258 git: gitIdentities.get(key) ?? null,
11891259 });
11901260 continue;
11911261 }
......@@ -1234,6 +1304,7 @@ export const make = Effect.gen(function* () {
12341304 ? null
12351305 : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)),
12361306 alreadyImported: importedProject !== undefined,
1307 git: entry.git,
12371308 });
12381309 }
12391310
apps/web/src/components/onboarding/WelcomeWizard.tsx+264-41
......@@ -1,6 +1,7 @@
11import { useAuth } from "@clerk/react";
22import { useAtomValue } from "@effect/atom-react";
33import type {
4 AgentSessionProjectCandidate,
45 EnvironmentId,
56 ProjectId,
67 ScopedProjectRef,
......@@ -32,10 +33,12 @@ import { hasCloudPublicConfig } from "../../cloud/publicConfig";
3233import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt";
3334import { useCompleteOnboarding } from "../../onboarding/firstRun";
3435import {
36 groupOnboardingProjects,
3537 partitionOnboardingProjects,
3638 onboardingProjectKey,
3739 resolveOnboardingLandingProject,
3840 resolveOnboardingProjectId,
41 type OnboardingProjectGroup,
3942} from "../../onboarding/projectImport.logic";
4043import {
4144 getOnboardingProviderState,
......@@ -59,6 +62,7 @@ import { getProviderSummary } from "../settings/providerStatus";
5962import { getDriverOption } from "../settings/providerDriverMeta";
6063import { TerminalViewport } from "../ThreadTerminalDrawer";
6164import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
65import { ClaudeAI, OpenAI } from "../Icons";
6266import { T3Wordmark } from "../T3Wordmark";
6367import { Button } from "../ui/button";
6468import { Checkbox } from "../ui/checkbox";
......@@ -71,6 +75,7 @@ import { WizardPanel, WizardSteps } from "../ui/wizard";
7175import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog";
7276import { toastManager } from "../ui/toast";
7377import { cn } from "../../lib/utils";
78import { formatRelativeTime } from "../../timestampFormat";
7479
7580/**
7681 * First-run welcome wizard. Rendered over the workspace at `/welcome` on a
......@@ -1008,11 +1013,11 @@ function ImportStep({
10081013 ),
10091014 [scans],
10101015 );
1011 const selected = candidates.filter((candidate) =>
1012 selectedPaths
1013 ? selectedPaths.has(candidate.key)
1014 : recent.some((item) => item.key === candidate.key),
1016 const selectedKeys = useMemo(
1017 () => selectedPaths ?? new Set(recent.map((candidate) => candidate.key)),
1018 [selectedPaths, recent],
10151019 );
1020 const selected = candidates.filter((candidate) => selectedKeys.has(candidate.key));
10161021
10171022 const finishAfterImport = () => {
10181023 const projectRef = resolveOnboardingLandingProject(
......@@ -1176,13 +1181,38 @@ function ImportStep({
11761181 title="Choose your projects"
11771182 description="Import projects and conversations from your selected computers."
11781183 >
1184 {candidates.length > 0 ? (
1185 <div className="mt-5 flex items-center justify-between gap-3 text-xs text-muted-foreground">
1186 <span role="status">
1187 {selected.length} of {candidates.length} selected
1188 </span>
1189 <div className="flex items-center gap-1">
1190 <Button
1191 variant="ghost"
1192 size="xs"
1193 disabled={isImporting || selected.length === candidates.length}
1194 onClick={() => setSelectedPaths(new Set(candidates.map((item) => item.key)))}
1195 >
1196 Select all
1197 </Button>
1198 <Button
1199 variant="ghost"
1200 size="xs"
1201 disabled={isImporting || selected.length === 0}
1202 onClick={() => setSelectedPaths(new Set())}
1203 >
1204 Select none
1205 </Button>
1206 </div>
1207 </div>
1208 ) : null}
11791209 <ScrollArea
11801210 scrollFade
1181 className="mt-5 h-auto max-h-80 [&_[data-slot=scroll-area-scrollbar]]:opacity-100"
1211 className="mt-2 h-auto max-h-80 [&_[data-slot=scroll-area-scrollbar]]:opacity-100"
11821212 >
11831213 <div className="space-y-5 pr-3">
11841214 {scans.map((scan) => {
1185 const groupCandidates = candidates.filter(
1215 const scanCandidates = candidates.filter(
11861216 (candidate) => candidate.environmentId === scan.environmentId,
11871217 );
11881218 const label =
......@@ -1191,10 +1221,12 @@ function ImportStep({
11911221 return (
11921222 <fieldset
11931223 key={scan.environmentId}
1194 className="min-w-0 space-y-1.5"
1224 className="min-w-0 space-y-0.5"
11951225 disabled={isImporting}
11961226 >
1197 <legend className="mb-2 text-sm font-medium">{label}</legend>
1227 {scans.length > 1 ? (
1228 <legend className="mb-2 text-sm font-medium">{label}</legend>
1229 ) : null}
11981230 {scan.isPending && scan.data === null ? (
11991231 <div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
12001232 <Spinner className="size-4" />
......@@ -1210,7 +1242,7 @@ function ImportStep({
12101242 Retry
12111243 </Button>
12121244 </div>
1213 ) : groupCandidates.length === 0 ? (
1245 ) : scanCandidates.length === 0 ? (
12141246 <p className="py-2 text-sm text-muted-foreground">
12151247 No existing Claude Code or Codex projects found.
12161248 </p>
......@@ -1220,38 +1252,11 @@ function ImportStep({
12201252 {SCAN_LIMIT_MESSAGE}
12211253 </p>
12221254 ) : null}
1223 {groupCandidates.map((candidate) => (
1224 <label
1225 key={candidate.key}
1226 className="flex cursor-pointer items-center gap-2.5 rounded-lg border border-border bg-background px-2.5 py-2 has-disabled:cursor-default"
1227 >
1228 <Checkbox
1229 checked={selected.some((item) => item.key === candidate.key)}
1230 onCheckedChange={(checked) => {
1231 const next = new Set(selected.map((item) => item.key));
1232 if (checked) next.add(candidate.key);
1233 else next.delete(candidate.key);
1234 setSelectedPaths(next);
1235 }}
1236 />
1237 <Tooltip>
1238 <TooltipTrigger
1239 render={<span className="min-w-0 flex-1 truncate font-mono text-xs" />}
1240 >
1241 {candidate.path}
1242 </TooltipTrigger>
1243 <TooltipPopup className="max-w-96 break-all font-mono">
1244 {candidate.path}
1245 </TooltipPopup>
1246 </Tooltip>
1247 <span className="shrink-0 whitespace-nowrap text-[11px] text-muted-foreground">
1248 {candidate.sources
1249 .map((source) => (source === "claudeAgent" ? "Claude" : "Codex"))
1250 .join(", ")}{" "}
1251 · {candidate.threadCount} {candidate.threadCount === 1 ? "thread" : "threads"}
1252 </span>
1253 </label>
1254 ))}
1255 <ImportCandidateList
1256 candidates={scanCandidates}
1257 selectedKeys={selectedKeys}
1258 onSelectionChange={setSelectedPaths}
1259 />
12551260 </fieldset>
12561261 );
12571262 })}
......@@ -1280,6 +1285,224 @@ function ImportStep({
12801285 );
12811286}
12821287
1288type ImportCandidate = AgentSessionProjectCandidate & {
1289 readonly environmentId: EnvironmentId;
1290 readonly key: string;
1291};
1292
1293/**
1294 * Repositories first, newest activity on top. Clones of one repository share
1295 * a group with a tri-state checkbox. Folders that are not git repositories
1296 * sit collapsed at the bottom so they stay reachable without adding noise.
1297 * Source icons appear only on repository rows so the columns stay still.
1298 */
1299function ImportCandidateList({
1300 candidates,
1301 selectedKeys,
1302 onSelectionChange,
1303}: {
1304 readonly candidates: ReadonlyArray<ImportCandidate>;
1305 readonly selectedKeys: ReadonlySet<string>;
1306 readonly onSelectionChange: (next: ReadonlySet<string>) => void;
1307}) {
1308 const { repositories, other } = useMemo(() => groupOnboardingProjects(candidates), [candidates]);
1309 const setKeys = (keys: ReadonlyArray<string>, checked: boolean) => {
1310 const next = new Set(selectedKeys);
1311 for (const key of keys) {
1312 if (checked) next.add(key);
1313 else next.delete(key);
1314 }
1315 onSelectionChange(next);
1316 };
1317 const otherSelected = other.filter((candidate) => selectedKeys.has(candidate.key)).length;
1318
1319 return (
1320 <>
1321 {repositories.map((group) => (
1322 <ImportRepositoryGroup
1323 key={group.key}
1324 group={group}
1325 selectedKeys={selectedKeys}
1326 onToggle={setKeys}
1327 />
1328 ))}
1329 {other.length > 0 ? (
1330 <Collapsible>
1331 <div className="flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-muted/40">
1332 <Checkbox
1333 checked={otherSelected === other.length}
1334 indeterminate={otherSelected > 0 && otherSelected < other.length}
1335 onCheckedChange={(checked) =>
1336 setKeys(
1337 other.map((candidate) => candidate.key),
1338 checked === true,
1339 )
1340 }
1341 />
1342 <CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-1.5 text-left">
1343 <ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
1344 <span className="truncate text-sm text-muted-foreground">Other folders</span>
1345 <span className="ml-auto shrink-0 text-xs text-muted-foreground tabular-nums">
1346 {other.length} {other.length === 1 ? "folder" : "folders"}
1347 </span>
1348 </CollapsibleTrigger>
1349 </div>
1350 <CollapsiblePanel>
1351 {other.map((candidate) => (
1352 <ImportCandidateRow
1353 key={candidate.key}
1354 candidate={candidate}
1355 label={candidate.path}
1356 nested
1357 checked={selectedKeys.has(candidate.key)}
1358 onCheckedChange={(checked) => setKeys([candidate.key], checked)}
1359 />
1360 ))}
1361 </CollapsiblePanel>
1362 </Collapsible>
1363 ) : null}
1364 </>
1365 );
1366}
1367
1368function ImportRepositoryGroup({
1369 group,
1370 selectedKeys,
1371 onToggle,
1372}: {
1373 readonly group: OnboardingProjectGroup<ImportCandidate>;
1374 readonly selectedKeys: ReadonlySet<string>;
1375 readonly onToggle: (keys: ReadonlyArray<string>, checked: boolean) => void;
1376}) {
1377 const keys = group.candidates.map((candidate) => candidate.key);
1378 const selectedCount = keys.filter((key) => selectedKeys.has(key)).length;
1379 const single = group.candidates.length === 1;
1380 const only = group.candidates[0];
1381 if (single && only !== undefined) {
1382 return (
1383 <ImportCandidateRow
1384 candidate={only}
1385 label={group.label}
1386 {...(group.repository === null ? {} : { secondary: only.path })}
1387 checked={selectedKeys.has(only.key)}
1388 onCheckedChange={(checked) => onToggle([only.key], checked)}
1389 />
1390 );
1391 }
1392 return (
1393 <Collapsible defaultOpen>
1394 <div className="flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-muted/40">
1395 <Checkbox
1396 checked={selectedCount === keys.length}
1397 indeterminate={selectedCount > 0 && selectedCount < keys.length}
1398 onCheckedChange={(checked) => onToggle(keys, checked === true)}
1399 />
1400 <CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-1.5 text-left">
1401 <ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
1402 <span className="truncate text-sm font-medium">{group.label}</span>
1403 <ImportRowMeta
1404 sources={[...new Set(group.candidates.flatMap((c) => c.sources))]}
1405 threadCount={group.threadCount}
1406 lastActiveAt={group.lastActiveAt}
1407 />
1408 </CollapsibleTrigger>
1409 </div>
1410 <CollapsiblePanel>
1411 {group.candidates.map((candidate) => (
1412 <ImportCandidateRow
1413 key={candidate.key}
1414 candidate={candidate}
1415 label={candidate.path}
1416 nested
1417 checked={selectedKeys.has(candidate.key)}
1418 onCheckedChange={(checked) => onToggle([candidate.key], checked)}
1419 />
1420 ))}
1421 </CollapsiblePanel>
1422 </Collapsible>
1423 );
1424}
1425
1426function ImportCandidateRow({
1427 candidate,
1428 label,
1429 secondary,
1430 nested = false,
1431 checked,
1432 onCheckedChange,
1433}: {
1434 readonly candidate: ImportCandidate;
1435 readonly label: string;
1436 readonly secondary?: string;
1437 readonly nested?: boolean;
1438 readonly checked: boolean;
1439 readonly onCheckedChange: (checked: boolean) => void;
1440}) {
1441 return (
1442 <label
1443 className={cn(
1444 "flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-muted/40 has-disabled:cursor-default",
1445 nested && "pl-8",
1446 )}
1447 >
1448 <Checkbox checked={checked} onCheckedChange={(value) => onCheckedChange(value === true)} />
1449 <Tooltip>
1450 <TooltipTrigger
1451 render={<span className="flex min-w-0 flex-1 items-baseline gap-2 truncate" />}
1452 >
1453 <span className={cn("truncate", nested ? "font-mono text-xs" : "text-sm font-medium")}>
1454 {label}
1455 </span>
1456 {secondary !== undefined ? (
1457 <span className="truncate font-mono text-[11px] text-muted-foreground">
1458 {secondary}
1459 </span>
1460 ) : null}
1461 </TooltipTrigger>
1462 <TooltipPopup className="max-w-96 break-all font-mono">{candidate.path}</TooltipPopup>
1463 </Tooltip>
1464 <ImportRowMeta
1465 sources={nested ? null : candidate.sources}
1466 threadCount={candidate.threadCount}
1467 lastActiveAt={candidate.lastActiveAt}
1468 />
1469 </label>
1470 );
1471}
1472
1473/**
1474 * Trailing columns shared by every import row: source icons, thread count,
1475 * last activity. Each column has a fixed width and each icon has its own slot
1476 * so nothing shifts between rows that differ in sources or digit count.
1477 */
1478function ImportRowMeta({
1479 sources,
1480 threadCount,
1481 lastActiveAt,
1482}: {
1483 readonly sources: ReadonlyArray<"claudeAgent" | "codex"> | null;
1484 readonly threadCount: number;
1485 readonly lastActiveAt: string | null;
1486}) {
1487 const relative = lastActiveAt === null ? null : formatRelativeTime(lastActiveAt);
1488 // "just now" does not fit the fixed column, so collapse it.
1489 const age = relative === null ? "" : relative.suffix === null ? "now" : relative.value;
1490 return (
1491 <span className="ml-auto grid shrink-0 grid-cols-[1rem_1rem_2.5rem_2.25rem] items-center gap-x-1 text-xs text-muted-foreground tabular-nums">
1492 <span className="flex size-4 items-center justify-center">
1493 {sources?.includes("claudeAgent") ? (
1494 <ClaudeAI className="size-3" aria-label="Claude Code" />
1495 ) : null}
1496 </span>
1497 <span className="flex size-4 items-center justify-center">
1498 {sources?.includes("codex") ? <OpenAI className="size-3" aria-label="Codex" /> : null}
1499 </span>
1500 <span className="text-right">{threadCount}</span>
1501 <span className="text-right whitespace-nowrap">{age}</span>
1502 </span>
1503 );
1504}
1505
12831506// ── Shared bits ──────────────────────────────────────────────
12841507
12851508function StepShell({
apps/web/src/onboarding/projectImport.logic.test.ts+69-1
......@@ -2,6 +2,7 @@ import { EnvironmentId, ProjectId, type AgentSessionProjectCandidate } from "@t3
22import { describe, expect, it } from "vite-plus/test";
33
44import {
5 groupOnboardingProjects,
56 partitionOnboardingProjects,
67 onboardingProjectKey,
78 resolveOnboardingLandingProject,
......@@ -18,13 +19,19 @@ function candidate(
1819 title: path.split("/").at(-1) ?? path,
1920 path,
2021 sources: ["codex"],
21 threadCount: 1,
22 threadCount: 3,
2223 lastActiveAt: "2026-08-20T12:00:00.000Z",
2324 alreadyImported: false,
25 git: { remoteKey: null, repository: null },
2426 ...overrides,
2527 };
2628}
2729
30const github = (repository: string) => ({
31 remoteKey: `github.com/${repository.toLowerCase()}`,
32 repository,
33});
34
2835describe("partitionOnboardingProjects", () => {
2936 it("keeps existing projects available for thread history import", () => {
3037 const imported = candidate("/projects/current", { alreadyImported: true });
......@@ -59,6 +66,67 @@ describe("partitionOnboardingProjects", () => {
5966 recent: [recent],
6067 });
6168 });
69
70 it("keeps non-git folders and thin histories out of the default selection", () => {
71 const repo = candidate("/projects/repo");
72 const folder = candidate("/projects/folder", { git: null });
73 const thin = candidate("/projects/thin", { threadCount: 2 });
74
75 expect(partitionOnboardingProjects([repo, folder, thin], now).recent).toEqual([repo]);
76 });
77});
78
79describe("groupOnboardingProjects", () => {
80 it("groups clones by origin, keeps local repos separate, and folds non-git folders away", () => {
81 const main = candidate("/code/t3code", {
82 git: github("pingdotgg/t3code"),
83 threadCount: 79,
84 lastActiveAt: "2026-08-21T12:00:00.000Z",
85 });
86 const clone = candidate("/code/clones/t3code-2", {
87 git: github("pingdotgg/t3code"),
88 threadCount: 13,
89 lastActiveAt: "2026-08-10T12:00:00.000Z",
90 });
91 const older = candidate("/code/fleet", {
92 git: github("t3dotgg/fleet"),
93 threadCount: 295,
94 lastActiveAt: "2026-08-22T00:00:00.000Z",
95 });
96 const local = candidate("/code/scratch-repo", { title: "scratch-repo" });
97 const folder = candidate("/tmp/notes", { git: null });
98
99 const grouped = groupOnboardingProjects([main, clone, older, local, folder]);
100
101 expect(grouped.other).toEqual([folder]);
102 expect(
103 grouped.repositories.map((group) => ({
104 label: group.label,
105 paths: group.candidates.map((item) => item.path),
106 threadCount: group.threadCount,
107 lastActiveAt: group.lastActiveAt,
108 })),
109 ).toEqual([
110 {
111 label: "t3dotgg/fleet",
112 paths: ["/code/fleet"],
113 threadCount: 295,
114 lastActiveAt: "2026-08-22T00:00:00.000Z",
115 },
116 {
117 label: "pingdotgg/t3code",
118 paths: ["/code/t3code", "/code/clones/t3code-2"],
119 threadCount: 92,
120 lastActiveAt: "2026-08-21T12:00:00.000Z",
121 },
122 {
123 label: "scratch-repo",
124 paths: ["/code/scratch-repo"],
125 threadCount: 3,
126 lastActiveAt: "2026-08-20T12:00:00.000Z",
127 },
128 ]);
129 });
62130});
63131
64132describe("resolveOnboardingProjectId", () => {
apps/web/src/onboarding/projectImport.logic.ts+81-1
......@@ -2,8 +2,14 @@ import { findProjectByPath } from "@t3tools/client-runtime/state/projects";
22import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts";
33
44const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
5/** One or two threads in a directory is usually a one-off question, not a project. */
6const DEFAULT_SELECTION_MIN_THREADS = 3;
57
6/** Existing projects still need their agent history imported, so every scan candidate is offered. */
8/**
9 * Existing projects still need their agent history imported, so every scan
10 * candidate is offered. The default selection is narrower: git repositories
11 * active in the last 30 days with enough threads to look like real work.
12 */
713export function partitionOnboardingProjects<T extends AgentSessionProjectCandidate>(
814 candidates: ReadonlyArray<T>,
915 now = Date.now(),
......@@ -13,6 +19,8 @@ export function partitionOnboardingProjects<T extends AgentSessionProjectCandida
1319 return {
1420 available: candidates,
1521 recent: candidates.filter((candidate) => {
22 if (candidate.git === null) return false;
23 if (candidate.threadCount < DEFAULT_SELECTION_MIN_THREADS) return false;
1624 if (candidate.lastActiveAt === null) return false;
1725 const lastActiveAt = Date.parse(candidate.lastActiveAt);
1826 return lastActiveAt >= cutoff && lastActiveAt <= now;
......@@ -20,6 +28,78 @@ export function partitionOnboardingProjects<T extends AgentSessionProjectCandida
2028 };
2129}
2230
31export interface OnboardingProjectGroup<T> {
32 /** Stable identity for collapse state and React keys. */
33 readonly key: string;
34 /** GitHub `owner/name`, or the checkout's folder name when the origin is elsewhere. */
35 readonly label: string;
36 readonly repository: string | null;
37 readonly candidates: ReadonlyArray<T>;
38 readonly threadCount: number;
39 readonly lastActiveAt: string | null;
40}
41
42function latestActivity(left: string | null, right: string | null): string | null {
43 if (left === null) return right;
44 if (right === null) return left;
45 return left > right ? left : right;
46}
47
48/**
49 * Group scan candidates for the onboarding picker. Clones of one repository
50 * share a group keyed by their normalized origin URL. Repositories without an
51 * origin get a group each. Directories that are not git repositories are
52 * returned separately so the UI can fold them away by default. Groups sort by
53 * most recent activity, newest first.
54 */
55export function groupOnboardingProjects<
56 T extends Pick<
57 AgentSessionProjectCandidate,
58 "path" | "title" | "git" | "threadCount" | "lastActiveAt"
59 >,
60>(candidates: ReadonlyArray<T>) {
61 const groups = new Map<string, OnboardingProjectGroup<T> & { candidates: Array<T> }>();
62 const other: Array<T> = [];
63
64 for (const candidate of candidates) {
65 if (candidate.git === null) {
66 other.push(candidate);
67 continue;
68 }
69 const key =
70 candidate.git.remoteKey === null
71 ? `path:${candidate.path}`
72 : `remote:${candidate.git.remoteKey}`;
73 const existing = groups.get(key);
74 if (existing === undefined) {
75 groups.set(key, {
76 key,
77 label: candidate.git.repository ?? candidate.title,
78 repository: candidate.git.repository,
79 candidates: [candidate],
80 threadCount: candidate.threadCount,
81 lastActiveAt: candidate.lastActiveAt,
82 });
83 continue;
84 }
85 existing.candidates.push(candidate);
86 groups.set(key, {
87 ...existing,
88 threadCount: existing.threadCount + candidate.threadCount,
89 lastActiveAt: latestActivity(existing.lastActiveAt, candidate.lastActiveAt),
90 });
91 }
92
93 const repositories = [...groups.values()].sort((left, right) => {
94 if (left.lastActiveAt === right.lastActiveAt) return left.label.localeCompare(right.label);
95 if (left.lastActiveAt === null) return 1;
96 if (right.lastActiveAt === null) return -1;
97 return right.lastActiveAt.localeCompare(left.lastActiveAt);
98 });
99
100 return { repositories, other };
101}
102
23103/** Use the server's project match before the client snapshot, which can lag behind the scan. */
24104export function resolveOnboardingProjectId(
25105 projects: ReadonlyArray<{
docs/user/welcome-wizard.md+10-3
......@@ -46,9 +46,16 @@ terminal metadata while the terminal process can use them.
4646
4747## Import your projects
4848
49T3 Code finds directories that Claude Code or Codex has used. The default
50selection includes projects active within the last 30 days. Use the checkboxes
51to include older projects or change the selection.
49T3 Code finds directories that Claude Code or Codex has used. Git repositories
50are listed first, newest activity on top. When the remote is on GitHub, the
51group shows the repository as `owner/name`. Clones with the same remote share
52one group. Directories that are not git repositories sit under "Other folders".
53
54The default selection includes git repositories active within the last 30 days
55with at least three conversations. Use the checkboxes, or "Select all" and
56"Select none", to change the selection. Linked git worktrees, Codex scratch
57directories under `Documents/Codex`, and anything under `Downloads` are not
58offered.
5259
5360A large or malformed history can reach the scan limit. T3 Code keeps the
5461projects it found and warns when projects or conversations may be missing.
packages/contracts/src/agentSessions.ts+14
......@@ -37,6 +37,18 @@ export type AgentSessionScanInput = typeof AgentSessionScanInput.Type;
3737 * T3 Code project. `alreadyImported` marks candidates that already have an
3838 * active project rooted at the same path.
3939 */
40/**
41 * Git identity of a candidate directory, read from `.git/config` without
42 * spawning git. `remoteKey` is the normalized origin URL, shared by every
43 * clone of the same repository so the client can group them. `repository`
44 * is the GitHub `owner/name` when the origin is on GitHub.
45 */
46export const AgentSessionProjectGit = Schema.Struct({
47 remoteKey: Schema.NullOr(Schema.String),
48 repository: Schema.NullOr(Schema.String),
49});
50export type AgentSessionProjectGit = typeof AgentSessionProjectGit.Type;
51
4052export const AgentSessionProjectCandidate = Schema.Struct({
4153 path: TrimmedNonEmptyString,
4254 title: TrimmedNonEmptyString,
......@@ -45,6 +57,8 @@ export const AgentSessionProjectCandidate = Schema.Struct({
4557 threadCount: NonNegativeInt,
4658 lastActiveAt: Schema.NullOr(IsoDateTime),
4759 alreadyImported: Schema.Boolean,
60 /** `null` when the directory is not the root of a git repository. */
61 git: Schema.NullOr(AgentSessionProjectGit),
4862});
4963export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type;
5064
packages/shared/src/git.test.ts+70
......@@ -7,6 +7,7 @@ import {
77 isTemporaryWorktreeBranch,
88 normalizeGitRemoteUrl,
99 parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
10 parseOriginUrlFromGitConfig,
1011 WORKTREE_BRANCH_PREFIX,
1112} from "./git.ts";
1213
......@@ -51,6 +52,72 @@ describe("normalizeGitRemoteUrl", () => {
5152 });
5253});
5354
55describe("parseOriginUrlFromGitConfig", () => {
56 it("reads the origin url and ignores other remotes", () => {
57 const config = [
58 "[core]",
59 "\trepositoryformatversion = 0",
60 '[remote "upstream"]',
61 "\turl = https://github.com/other/repo.git",
62 '[remote "origin"]',
63 "\turl = git@github.com:pingdotgg/t3code.git",
64 "\tfetch = +refs/heads/*:refs/remotes/origin/*",
65 '[branch "main"]',
66 "\tremote = origin",
67 ].join("\n");
68 expect(parseOriginUrlFromGitConfig(config)).toBe("git@github.com:pingdotgg/t3code.git");
69 });
70
71 it("strips inline comments and quotes from the url value", () => {
72 expect(
73 parseOriginUrlFromGitConfig(
74 '[remote "origin"]\n\turl = https://github.com/acme/repo.git # mirror\n',
75 ),
76 ).toBe("https://github.com/acme/repo.git");
77 expect(
78 parseOriginUrlFromGitConfig('[remote "origin"]\n\turl = "git@github.com:acme/repo.git"\n'),
79 ).toBe("git@github.com:acme/repo.git");
80 });
81
82 it("accepts legacy dotted headers, header comments, and line continuations", () => {
83 expect(parseOriginUrlFromGitConfig("[remote.origin]\n\turl = git@github.com:a/b.git\n")).toBe(
84 "git@github.com:a/b.git",
85 );
86 // Git folds the dotted form to lowercase but keeps quoted names as written.
87 expect(parseOriginUrlFromGitConfig("[remote.Origin]\n\turl = git@github.com:a/b.git\n")).toBe(
88 "git@github.com:a/b.git",
89 );
90 expect(
91 parseOriginUrlFromGitConfig(
92 '[remote "Origin"]\n\turl = git@github.com:x/y.git\n[remote "origin"]\n\turl = git@github.com:a/b.git\n',
93 ),
94 ).toBe("git@github.com:a/b.git");
95 expect(
96 parseOriginUrlFromGitConfig('[remote "origin"] # primary\n\turl = git@github.com:a/b.git\n'),
97 ).toBe("git@github.com:a/b.git");
98 expect(
99 parseOriginUrlFromGitConfig(
100 '[remote "origin"]\n\turl = https://github.com/acme/\\\n\t\trepo.git\n',
101 ),
102 ).toBe("https://github.com/acme/repo.git");
103 });
104
105 it("falls back to the first remote when there is no origin", () => {
106 const config = [
107 '[remote "upstream"]',
108 "\turl = https://github.com/acme/repo.git",
109 '[remote "fork"]',
110 "\turl = https://github.com/me/repo.git",
111 ].join("\n");
112 expect(parseOriginUrlFromGitConfig(config)).toBe("https://github.com/acme/repo.git");
113 });
114
115 it("returns null when there is no remote section", () => {
116 expect(parseOriginUrlFromGitConfig("[core]\n\tbare = false\n")).toBeNull();
117 expect(parseOriginUrlFromGitConfig("")).toBeNull();
118 });
119});
120
54121describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => {
55122 it("extracts the owner and repository from common GitHub remote shapes", () => {
56123 expect(
......@@ -59,6 +126,9 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => {
59126 expect(
60127 parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"),
61128 ).toBe("T3Tools/T3Code");
129 expect(
130 parseGitHubRepositoryNameWithOwnerFromRemoteUrl("ssh://github.com/T3Tools/T3Code.git"),
131 ).toBe("T3Tools/T3Code");
62132 });
63133});
64134
packages/shared/src/git.ts+65-1
......@@ -143,6 +143,70 @@ export function normalizeGitRemoteUrl(value: string): string {
143143 return normalized;
144144}
145145
146/**
147 * Unquote a git config value: strip an inline `#` or `;` comment outside
148 * quotes, then drop surrounding quotes and backslash escapes.
149 */
150function parseGitConfigValue(raw: string): string {
151 let out = "";
152 let quoted = false;
153 for (let index = 0; index < raw.length; index += 1) {
154 const char = raw[index]!;
155 if (char === "\\" && index + 1 < raw.length) {
156 out += raw[index + 1];
157 index += 1;
158 continue;
159 }
160 if (char === '"') {
161 quoted = !quoted;
162 continue;
163 }
164 if (!quoted && (char === "#" || char === ";")) break;
165 out += char;
166 }
167 return out.trim();
168}
169
170/**
171 * Read the primary remote URL from raw `.git/config` text without spawning
172 * git. Prefers `remote.origin.url` and falls back to the first remote so
173 * clones made with `git clone --origin <name>` still resolve.
174 */
175export function parseOriginUrlFromGitConfig(configText: string): string | null {
176 let section: string | null = null;
177 let originUrl: string | null = null;
178 let firstRemoteUrl: string | null = null;
179 // A trailing backslash continues the value on the next line.
180 const joined = configText.replace(/\\\r?\n[ \t]*/g, "");
181 for (const rawLine of joined.split(/\r?\n/)) {
182 const line = rawLine.trim();
183 if (line.length === 0 || line.startsWith("#") || line.startsWith(";")) continue;
184 // Both `[remote "origin"]` and the legacy `[remote.origin]` form, with an
185 // optional trailing comment. Git keeps quoted subsections case-sensitive
186 // but folds the dotted form to lowercase.
187 const header = /^\[\s*remote(?:\s+"([^"]+)"|\.([^\]\s]+))\s*\](?:\s*[#;].*)?$/i.exec(line);
188 if (header) {
189 section = header[1] ?? header[2]?.toLowerCase() ?? null;
190 continue;
191 }
192 if (line.startsWith("[")) {
193 section = null;
194 continue;
195 }
196 if (section === null) continue;
197 const match = /^url\s*=\s*(.*)$/i.exec(line);
198 if (!match) continue;
199 const url = parseGitConfigValue(match[1] ?? "");
200 if (url.length === 0) continue;
201 if (section === "origin") {
202 originUrl ??= url;
203 } else {
204 firstRemoteUrl ??= url;
205 }
206 }
207 return originUrl ?? firstRemoteUrl;
208}
209
146210/**
147211 * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes.
148212 */
......@@ -153,7 +217,7 @@ export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | nu
153217 }
154218
155219 const match =
156 /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(
220 /^(?:git@github\.com:|ssh:\/\/(?:git@)?github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(
157221 trimmed,
158222 );
159223 const repositoryNameWithOwner = match?.[1]?.trim() ?? "";