Skip to content
Merged
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
118 changes: 118 additions & 0 deletions apps/server/src/project/AgentSessionScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
threadCount: 1,
lastActiveAt: "2026-03-01T00:00:00.000Z",
alreadyImported: false,
git: null,
},
{
path: olderWorkspace,
Expand All @@ -221,6 +222,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
threadCount: 2,
lastActiveAt: "2026-01-02T00:00:00.000Z",
alreadyImported: false,
git: null,
},
]);
}),
Expand Down Expand Up @@ -263,6 +265,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
threadCount: 1,
lastActiveAt: "2026-02-09T11:00:00.000Z",
alreadyImported: false,
git: null,
},
{
path: workspace,
Expand All @@ -271,6 +274,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
threadCount: 2,
lastActiveAt: "2026-02-09T10:00:00.000Z",
alreadyImported: false,
git: null,
},
]);
}),
Expand Down Expand Up @@ -389,6 +393,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
threadCount: 2,
lastActiveAt: "2026-04-01T09:00:00.000Z",
alreadyImported: true,
git: null,
},
]);
}),
Expand Down Expand Up @@ -421,6 +426,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
path: workspace,
projectId: ProjectId.make("project-1"),
alreadyImported: true,
git: null,
});
}),
);
Expand Down Expand Up @@ -452,6 +458,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
path: workspaceAlias,
projectId: ProjectId.make("project-1"),
alreadyImported: true,
git: null,
});
}),
);
Expand Down Expand Up @@ -498,6 +505,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
threadCount: 2,
lastActiveAt: "2026-01-02T00:00:00.000Z",
alreadyImported: true,
git: null,
},
]);
}),
Expand Down Expand Up @@ -855,6 +863,115 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
}),
);

it.effect("excludes Codex scratch directories and Downloads", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const fileSystem = yield* FileSystem.FileSystem;
const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
const codexHomePath = yield* makeTempDir("t3code-codex-home-");
// The exclusions key off the real home directory, so these fixtures
// must live there. Each run owns a uniquely named subtree and removes
// only that subtree, never the shared Codex or Downloads parents.
const home = NodeOS.homedir();
// Borrow a unique suffix from a scoped temp dir instead of reaching for
// Date.now or Math.random, which the Effect lint rejects.
const runId = path.basename(yield* makeTempDir("t3code-scanner-test-"));
const scratchRoot = path.join(home, "Documents", "Codex", runId);
const scratch = path.join(scratchRoot, "2026-09-01", "some-conversation");
const downloads = path.join(home, "Downloads", runId);
const keep = yield* makeTempDir("t3code-workspace-keep-");
yield* fileSystem.makeDirectory(scratch, { recursive: true });
yield* fileSystem.makeDirectory(downloads, { recursive: true });
yield* Effect.addFinalizer(() =>
Effect.all([
fileSystem.remove(scratchRoot, { recursive: true }).pipe(Effect.ignore),
fileSystem.remove(downloads, { recursive: true }).pipe(Effect.ignore),
]),
);

for (const [index, cwd] of [scratch, downloads, keep].entries()) {
yield* writeTranscript({
filePath: path.join(
codexHomePath,
"sessions",
"2026",
"09",
"01",
`rollout-${index}.jsonl`,
),
contents: codexRolloutLine(cwd),
mtimeMs: Date.parse("2026-09-01T00:00:00.000Z"),
});
}

const result = yield* runScan({ claudeHomePath, codexHomePath });

expect(result.candidates.map((candidate) => candidate.path)).toEqual([keep]);
}),
);

it.effect("skips linked git worktrees and reports the origin of real checkouts", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const fileSystem = yield* FileSystem.FileSystem;
const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
const codexHomePath = yield* makeTempDir("t3code-codex-home-");
const repo = yield* makeTempDir("t3code-workspace-repo-");
const worktree = yield* makeTempDir("t3code-workspace-worktree-");
const plain = yield* makeTempDir("t3code-workspace-plain-");
const noRemote = yield* makeTempDir("t3code-workspace-noremote-");
const submodule = yield* makeTempDir("t3code-workspace-submodule-");

yield* fileSystem.makeDirectory(path.join(repo, ".git"));
yield* fileSystem.writeFileString(
path.join(repo, ".git", "config"),
'[core]\n\tbare = false\n[remote "origin"]\n\turl = git@github.com:pingdotgg/t3code.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n',
);
yield* fileSystem.writeFileString(
path.join(worktree, ".git"),
`gitdir: ${path.join(repo, ".git", "worktrees", "wt")}\n`,
);
yield* fileSystem.makeDirectory(path.join(noRemote, ".git"));
yield* fileSystem.writeFileString(path.join(noRemote, ".git", "config"), "[core]\n");
// Submodules also use a gitdir pointer, but into `modules/`, not `worktrees/`.
const submoduleGitDir = path.join(repo, ".git", "modules", "vendor");
yield* fileSystem.makeDirectory(submoduleGitDir, { recursive: true });
yield* fileSystem.writeFileString(
path.join(submoduleGitDir, "config"),
'[remote "origin"]\n\turl = ssh://github.com/pingdotgg/vendor.git\n',
);
yield* fileSystem.writeFileString(
path.join(submodule, ".git"),
`gitdir: ${submoduleGitDir}\n`,
);

for (const [index, cwd] of [repo, worktree, plain, noRemote, submodule].entries()) {
yield* writeTranscript({
filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"),
contents: claudeSessionLine(cwd),
mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`),
});
}

const result = yield* runScan({ claudeHomePath, codexHomePath });

expect(
result.candidates.map((candidate) => ({ path: candidate.path, git: candidate.git })),
).toEqual([
{
path: submodule,
git: { remoteKey: "github.com/pingdotgg/vendor", repository: "pingdotgg/vendor" },
},
{ path: noRemote, git: { remoteKey: null, repository: null } },
{ path: plain, git: null },
{
path: repo,
git: { remoteKey: "github.com/pingdotgg/t3code", repository: "pingdotgg/t3code" },
},
]);
}),
);

it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
Expand Down Expand Up @@ -1231,6 +1348,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
threadCount: 1,
lastActiveAt: "2026-05-03T00:00:00.000Z",
alreadyImported: false,
git: null,
},
]);
}),
Expand Down
75 changes: 73 additions & 2 deletions apps/server/src/project/AgentSessionScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import * as Stream from "effect/Stream";

import {
normalizeGitRemoteUrl,
parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
parseOriginUrlFromGitConfig,
} from "@t3tools/shared/git";
import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { normalizeProjectPathForComparison } from "@t3tools/shared/path";

Expand Down Expand Up @@ -624,14 +629,28 @@ export const make = Effect.gen(function* () {
// must case fold.
const foldWorktreeCase = (yield* HostProcessPlatform) === "win32";
const hostEnvironment = yield* HostProcessEnvironment;
const homeDir = NodeOS.homedir();
// `/private/tmp` is what macOS reports for sessions started in `/tmp`.
const excludedProjectRoots = new Set(
[NodeOS.homedir(), NodeOS.tmpdir()].map((directory) =>
[homeDir, NodeOS.tmpdir(), "/tmp", "/private/tmp"].map((directory) =>
normalizeProjectPathForComparison(path.resolve(directory)),
),
);
// Codex creates one scratch directory per conversation under
// ~/Documents/Codex/<date>/<slug>. Neither those nor anything a user
// unpacked into Downloads is a project.
const excludedProjectAncestors = [
path.join(homeDir, "Downloads"),
path.join(homeDir, "Documents", "Codex"),
];

const isExcludedProjectPath = (candidatePath: string) =>
excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) ||
excludedProjectAncestors.some((ancestor) =>
normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith(
normalizeForWorktreeMatch(ancestor, foldWorktreeCase),
),
) ||
normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith(
normalizeForWorktreeMatch(baseDir, foldWorktreeCase),
) ||
Expand Down Expand Up @@ -664,6 +683,48 @@ export const make = Effect.gen(function* () {
return `path:${normalizeProjectPathForComparison(realPath)}`;
});

/**
* Git identity of a directory, or the reason it has none. Reads `.git`
* directly instead of spawning git so a scan over hundreds of candidates
* stays cheap. A `.git` file is a `gitdir:` pointer. When it points into a
* `worktrees/` directory the checkout is a linked worktree, which
* onboarding skips because its history belongs to the main checkout.
* Submodules use the same pointer shape but live under `modules/`, and
* are offered like any other repository.
*/
const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* (
directory: string,
): Effect.fn.Return<
| { readonly _tag: "Repository"; readonly git: AgentSessionProjectCandidate["git"] }
| { readonly _tag: "Worktree" }
| { readonly _tag: "NotGit" }
> {
const gitPath = path.join(directory, ".git");
const gitStats = yield* statOption(gitPath);
if (Option.isNone(gitStats)) return { _tag: "NotGit" } as const;
let gitDir = gitPath;
if (gitStats.value.type !== "Directory") {
const pointer = yield* fileSystem
.readFileString(gitPath)
.pipe(Effect.orElseSucceed(() => ""));
const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim();
if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const;
gitDir = path.resolve(directory, target);
if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) return { _tag: "Worktree" } as const;
}
const configText = yield* fileSystem
.readFileString(path.join(gitDir, "config"))
.pipe(Effect.orElseSucceed(() => ""));
const originUrl = parseOriginUrlFromGitConfig(configText);
return {
_tag: "Repository",
git: {
remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl),
repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl),
},
} as const;
});

// A large history snapshot can precede session metadata. Read bounded
// chunks until a complete record names its cwd or the safety budget ends.
const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* (
Expand Down Expand Up @@ -1148,9 +1209,11 @@ export const make = Effect.gen(function* () {
sources: Array<AgentSessionSource>;
threadCount: number;
lastActiveAtMs: number | null;
git: AgentSessionProjectCandidate["git"];
}
>();
const directoryKeys = new Map<string, string>();
const gitIdentities = new Map<string, AgentSessionProjectCandidate["git"]>();

for (const candidate of raw) {
const expanded = expandHomePath(candidate.cwd.trim());
Expand All @@ -1173,7 +1236,13 @@ export const make = Effect.gen(function* () {
if (isExcludedProjectPath(realPath)) {
key = "";
} else {
key = yield* directoryIdentity(resolved, stats.value);
const gitIdentity = yield* readGitIdentity(resolved);
if (gitIdentity._tag === "Worktree") {
key = "";
} else {
key = yield* directoryIdentity(resolved, stats.value);
gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null);
}
}
directoryKeys.set(resolved, key);
}
Expand All @@ -1186,6 +1255,7 @@ export const make = Effect.gen(function* () {
sources: [candidate.source],
threadCount: candidate.threadCount,
lastActiveAtMs: candidate.lastActiveAtMs,
git: gitIdentities.get(key) ?? null,
});
continue;
}
Expand Down Expand Up @@ -1234,6 +1304,7 @@ export const make = Effect.gen(function* () {
? null
: DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)),
alreadyImported: importedProject !== undefined,
git: entry.git,
});
}

Expand Down
Loading
Loading