Skip to content

Commit 8b2838e

Browse files
authored
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.
1 parent dc39615 commit 8b2838e

9 files changed

Lines changed: 764 additions & 49 deletions

File tree

apps/server/src/project/AgentSessionScanner.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ import * as Schema from "effect/Schema";
3838
import * as Semaphore from "effect/Semaphore";
3939
import * as Stream from "effect/Stream";
4040

41+
import {
42+
normalizeGitRemoteUrl,
43+
parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
44+
parseOriginUrlFromGitConfig,
45+
} from "@t3tools/shared/git";
4146
import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4247
import { 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

0 commit comments

Comments
 (0)