import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { TestClock } from "effect/testing"; import * as ProcessRunner from "../processRunner.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); const normalizeResolvedPath = (value: string) => normalizePathSeparators(value); const git = (cwd: string, args: ReadonlyArray) => Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; return yield* processRunner.run({ command: "git", args: ["-C", cwd, ...args], }); }).pipe(Effect.provide(ProcessRunner.layer)); const makeRepositoryIdentityResolverTestLayer = (options: { readonly positiveCacheTtl?: Duration.Input; readonly negativeCacheTtl?: Duration.Input; }) => Layer.effect( RepositoryIdentityResolver.RepositoryIdentityResolver, RepositoryIdentityResolver.make({ cacheCapacity: 16, ...options, }), ).pipe(Layer.provide(ProcessRunner.layer)); it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-repository-identity-test-", }); yield* git(cwd, ["init"]); yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); const resolvedIdentityRoot = identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); const resolvedCwd = yield* fileSystem.realPath(cwd); expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe(normalizeResolvedPath(resolvedCwd)); expect(identity?.displayName).toBe("t3tools/t3code"); expect(identity?.provider).toBe("github"); expect(identity?.owner).toBe("t3tools"); expect(identity?.name).toBe("t3code"); }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect("returns the git top-level root path when resolving from a nested workspace", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const repoRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-repository-identity-nested-root-test-", }); const nestedWorkspace = path.join(repoRoot, "packages", "web"); yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true }); yield* git(repoRoot, ["init"]); yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(nestedWorkspace); const resolvedIdentityRoot = identity?.rootPath === undefined ? "" : yield* fileSystem.realPath(identity.rootPath); const resolvedRepoRoot = yield* fileSystem.realPath(repoRoot); expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe( normalizeResolvedPath(resolvedRepoRoot), ); }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect("returns null for non-git folders and repos without remotes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-repository-identity-non-git-", }); const gitDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-repository-identity-no-remote-", }); yield* git(gitDir, ["init"]); const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const nonGitIdentity = yield* resolver.resolve(nonGitDir); const noRemoteIdentity = yield* resolver.resolve(gitDir); expect(nonGitIdentity).toBeNull(); expect(noRemoteIdentity).toBeNull(); }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect("prefers upstream over origin when both remotes are configured", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-repository-identity-upstream-test-", }); yield* git(cwd, ["init"]); yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); expect(identity).not.toBeNull(); expect(identity?.locator.remoteName).toBe("upstream"); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); expect(identity?.displayName).toBe("t3tools/t3code"); }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect("uses the last remote path segment as the repository name for nested groups", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-repository-identity-nested-group-test-", }); yield* git(cwd, ["init"]); yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]); const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code"); expect(identity?.displayName).toBe("t3tools/platform/t3code"); expect(identity?.owner).toBe("t3tools"); expect(identity?.name).toBe("t3code"); }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect( "keeps null identities cached across repeated resolves until the negative TTL expires", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-repository-identity-late-remote-test-", }); yield* git(cwd, ["init"]); const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const initialIdentity = yield* resolver.resolve(cwd); expect(initialIdentity).toBeNull(); yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); for (const _attempt of [1, 2, 3]) { const cachedIdentity = yield* resolver.resolve(cwd); expect(cachedIdentity).toBeNull(); } yield* TestClock.adjust(Duration.millis(120)); const refreshedIdentity = yield* resolver.resolve(cwd); expect(refreshedIdentity).not.toBeNull(); expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); expect(refreshedIdentity?.name).toBe("t3code"); }).pipe( Effect.provide( Layer.merge( TestClock.layer(), makeRepositoryIdentityResolverTestLayer({ negativeCacheTtl: Duration.millis(50), positiveCacheTtl: Duration.seconds(1), }), ), ), ), ); it.effect("refreshes cached identities after the positive TTL when a remote changes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-repository-identity-remote-change-test-", }); yield* git(cwd, ["init"]); yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const initialIdentity = yield* resolver.resolve(cwd); expect(initialIdentity).not.toBeNull(); expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]); const cachedIdentity = yield* resolver.resolve(cwd); expect(cachedIdentity).not.toBeNull(); expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); yield* TestClock.adjust(Duration.millis(180)); const refreshedIdentity = yield* resolver.resolve(cwd); expect(refreshedIdentity).not.toBeNull(); expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next"); expect(refreshedIdentity?.displayName).toBe("t3tools/t3code-next"); expect(refreshedIdentity?.name).toBe("t3code-next"); }).pipe( Effect.provide( Layer.merge( TestClock.layer(), makeRepositoryIdentityResolverTestLayer({ negativeCacheTtl: Duration.millis(50), positiveCacheTtl: Duration.millis(100), }), ), ), ), ); });