| author | |
| committer | |
| log | 017c9a0eb53b2c100b5be2d366d2374038da9241 |
| tree | 567dffe34707e0742bf284561ba594bb9f17cf8e |
| parent | 99db70cf1a1f24deca80e40a94a6cdbbaf511da6 |
| signature | Signed by PGP key B5690EEEBB952194 |
Fixes #4880. Originally #14639 by @Project516.
Co-authored-by: Project516 <138796702+Project516@users.noreply.github.com>8 files changed, 144 insertions(+), 15 deletions(-)
apps/mobile/src/features/threads/new-task-flow-provider.tsx+7-2| ... | @@ -16,6 +16,7 @@ import { | ... | @@ -16,6 +16,7 @@ import { |
| 16 | DEFAULT_RUNTIME_MODE, | 16 | DEFAULT_RUNTIME_MODE, |
| 17 | DEFAULT_SERVER_SETTINGS, | 17 | DEFAULT_SERVER_SETTINGS, |
| 18 | MessageId, | 18 | MessageId, |
| 19 | repositoryGroupingKeyOf, | ||
| 19 | T3_PROJECT_FILE_NAME, | 20 | T3_PROJECT_FILE_NAME, |
| 20 | ThreadId, | 21 | ThreadId, |
| 21 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| ... | @@ -393,7 +394,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -393,7 +394,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 393 | // whatever unrelated project happens to be first on the other machine. Repository | 394 | // whatever unrelated project happens to be first on the other machine. Repository |
| 394 | // identity is the primary signal; projects that haven't reported one yet (still | 395 | // identity is the primary signal; projects that haven't reported one yet (still |
| 395 | // indexing) fall back to workspace basename / title so a valid host isn't hidden. | 396 | // indexing) fall back to workspace basename / title so a valid host isn't hidden. |
| 396 | const selectedRepositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; | 397 | const selectedRepositoryKey = selectedProject?.repositoryIdentity |
| 398 | ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) | ||
| 399 | : null; | ||
| 397 | // `|| null` (not `??`): a pending-task placeholder project can have an empty | 400 | // `|| null` (not `??`): a pending-task placeholder project can have an empty |
| 398 | // workspaceRoot, and an "" basename would reject every real host below. | 401 | // workspaceRoot, and an "" basename would reject every real host below. |
| 399 | const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; | 402 | const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; |
| ... | @@ -414,7 +417,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -414,7 +417,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 414 | if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { | 417 | if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { |
| 415 | return true; | 418 | return true; |
| 416 | } | 419 | } |
| 417 | const projectKey = project.repositoryIdentity?.canonicalKey ?? null; | 420 | const projectKey = project.repositoryIdentity |
| 421 | ? repositoryGroupingKeyOf(project.repositoryIdentity) | ||
| 422 | : null; | ||
| 418 | if (selectedRepositoryKey !== null && projectKey !== null) { | 423 | if (selectedRepositoryKey !== null && projectKey !== null) { |
| 419 | return projectKey === selectedRepositoryKey; | 424 | return projectKey === selectedRepositoryKey; |
| 420 | } | 425 | } |
apps/mobile/src/features/threads/new-task-project-selection.ts+11-4| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 1 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | 2 | import { repositoryGroupingKeyOf, type EnvironmentId } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 4 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 5 | import type { HomeProjectScope } from "../home/homeThreadList"; | 5 | import type { HomeProjectScope } from "../home/homeThreadList"; |
| ... | @@ -56,7 +56,9 @@ export function resolveEnvironmentProjectMatch( | ... | @@ -56,7 +56,9 @@ export function resolveEnvironmentProjectMatch( |
| 56 | projectsOnTarget: ReadonlyArray<EnvironmentProject>, | 56 | projectsOnTarget: ReadonlyArray<EnvironmentProject>, |
| 57 | selectedProject: EnvironmentProject | null, | 57 | selectedProject: EnvironmentProject | null, |
| 58 | ): EnvironmentProject | null { | 58 | ): EnvironmentProject | null { |
| 59 | const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; | 59 | const repositoryKey = selectedProject?.repositoryIdentity |
| 60 | ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) | ||
| 61 | : null; | ||
| 60 | // `|| null` (not `??`): a pending-task placeholder project can have an empty | 62 | // `|| null` (not `??`): a pending-task placeholder project can have an empty |
| 61 | // workspaceRoot, and an "" basename would match nothing meaningful. | 63 | // workspaceRoot, and an "" basename would match nothing meaningful. |
| 62 | const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; | 64 | const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; |
| ... | @@ -64,13 +66,18 @@ export function resolveEnvironmentProjectMatch( | ... | @@ -64,13 +66,18 @@ export function resolveEnvironmentProjectMatch( |
| 64 | // side; two known, different repositories never match on a shared basename | 66 | // side; two known, different repositories never match on a shared basename |
| 65 | // or title (mirrors the environment list filter in the new-task flow). | 67 | // or title (mirrors the environment list filter in the new-task flow). |
| 66 | const isKnownMismatch = (project: EnvironmentProject) => { | 68 | const isKnownMismatch = (project: EnvironmentProject) => { |
| 67 | const projectKey = project.repositoryIdentity?.canonicalKey ?? null; | 69 | const projectKey = project.repositoryIdentity |
| 70 | ? repositoryGroupingKeyOf(project.repositoryIdentity) | ||
| 71 | : null; | ||
| 68 | return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey; | 72 | return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey; |
| 69 | }; | 73 | }; |
| 70 | return ( | 74 | return ( |
| 71 | (repositoryKey !== null | 75 | (repositoryKey !== null |
| 72 | ? projectsOnTarget.find( | 76 | ? projectsOnTarget.find( |
| 73 | (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, | 77 | (project) => |
| 78 | (project.repositoryIdentity | ||
| 79 | ? repositoryGroupingKeyOf(project.repositoryIdentity) | ||
| 80 | : null) === repositoryKey, | ||
| 74 | ) | 81 | ) |
| 75 | : undefined) ?? | 82 | : undefined) ?? |
| 76 | (workspaceBasename !== null | 83 | (workspaceBasename !== null |
apps/server/src/project/RepositoryIdentityResolver.test.ts+23| ... | @@ -288,6 +288,29 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { | ... | @@ -288,6 +288,29 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { |
| 288 | }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), | 288 | }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), |
| 289 | ); | 289 | ); |
| 290 | 290 | ||
| 291 | it.effect("reports a fork's own remote as origin next to the upstream identity", () => | ||
| 292 | Effect.gen(function* () { | ||
| 293 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 294 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 295 | prefix: "t3-repository-identity-fork-test-", | ||
| 296 | }); | ||
| 297 | |||
| 298 | yield* git(cwd, ["init"]); | ||
| 299 | yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code-fork.git"]); | ||
| 300 | yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); | ||
| 301 | |||
| 302 | const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | ||
| 303 | const identity = yield* resolver.resolve(cwd); | ||
| 304 | |||
| 305 | expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); | ||
| 306 | expect(identity?.displayName).toBe("t3tools/t3code"); | ||
| 307 | expect(identity?.origin).toEqual({ | ||
| 308 | canonicalKey: "github.com/julius/t3code-fork", | ||
| 309 | displayName: "julius/t3code-fork", | ||
| 310 | }); | ||
| 311 | }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), | ||
| 312 | ); | ||
| 313 | |||
| 291 | it.effect("uses the last remote path segment as the repository name for nested groups", () => | 314 | it.effect("uses the last remote path segment as the repository name for nested groups", () => |
| 292 | Effect.gen(function* () { | 315 | Effect.gen(function* () { |
| 293 | const fileSystem = yield* FileSystem.FileSystem; | 316 | const fileSystem = yield* FileSystem.FileSystem; |
apps/server/src/project/RepositoryIdentityResolver.ts+24-3| ... | @@ -70,17 +70,34 @@ function pickPrimaryRemote( | ... | @@ -70,17 +70,34 @@ function pickPrimaryRemote( |
| 70 | return remoteName && remoteUrl ? { remoteName, remoteUrl } : null; | 70 | return remoteName && remoteUrl ? { remoteName, remoteUrl } : null; |
| 71 | } | 71 | } |
| 72 | 72 | ||
| 73 | function repositoryPathOf(canonicalKey: string): string { | ||
| 74 | return canonicalKey.split("/").slice(1).join("/"); | ||
| 75 | } | ||
| 76 | |||
| 77 | function buildRepositoryOrigin( | ||
| 78 | originUrl: string | undefined, | ||
| 79 | canonicalKey: string, | ||
| 80 | ): RepositoryIdentity["origin"] { | ||
| 81 | if (!originUrl) return undefined; | ||
| 82 | const originKey = normalizeGitRemoteUrl(originUrl); | ||
| 83 | if (originKey === canonicalKey) return undefined; | ||
| 84 | const displayName = repositoryPathOf(originKey); | ||
| 85 | return { canonicalKey: originKey, ...(displayName ? { displayName } : {}) }; | ||
| 86 | } | ||
| 87 | |||
| 73 | function buildRepositoryIdentity(input: { | 88 | function buildRepositoryIdentity(input: { |
| 74 | readonly remoteName: string; | 89 | readonly remoteName: string; |
| 75 | readonly remoteUrl: string; | 90 | readonly remoteUrl: string; |
| 91 | readonly originUrl: string | undefined; | ||
| 76 | readonly rootPath: string; | 92 | readonly rootPath: string; |
| 77 | }): RepositoryIdentity { | 93 | }): RepositoryIdentity { |
| 78 | const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); | 94 | const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); |
| 79 | const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); | 95 | const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); |
| 80 | const repositoryPath = canonicalKey.split("/").slice(1).join("/"); | 96 | const repositoryPath = repositoryPathOf(canonicalKey); |
| 81 | const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); | 97 | const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); |
| 82 | const [owner] = repositoryPathSegments; | 98 | const [owner] = repositoryPathSegments; |
| 83 | const repositoryName = repositoryPathSegments.at(-1); | 99 | const repositoryName = repositoryPathSegments.at(-1); |
| 100 | const origin = buildRepositoryOrigin(input.originUrl, canonicalKey); | ||
| 84 | 101 | ||
| 85 | return { | 102 | return { |
| 86 | canonicalKey, | 103 | canonicalKey, |
| ... | @@ -94,6 +111,7 @@ function buildRepositoryIdentity(input: { | ... | @@ -94,6 +111,7 @@ function buildRepositoryIdentity(input: { |
| 94 | ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}), | 111 | ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}), |
| 95 | ...(owner ? { owner } : {}), | 112 | ...(owner ? { owner } : {}), |
| 96 | ...(repositoryName ? { name: repositoryName } : {}), | 113 | ...(repositoryName ? { name: repositoryName } : {}), |
| 114 | ...(origin ? { origin } : {}), | ||
| 97 | }; | 115 | }; |
| 98 | } | 116 | } |
| 99 | 117 | ||
| ... | @@ -136,8 +154,11 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( | ... | @@ -136,8 +154,11 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( |
| 136 | return null; | 154 | return null; |
| 137 | } | 155 | } |
| 138 | 156 | ||
| 139 | const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); | 157 | const remotes = parseRemoteFetchUrls(remoteResult.value.stdout); |
| 140 | return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; | 158 | const remote = pickPrimaryRemote(remotes); |
| 159 | return remote | ||
| 160 | ? buildRepositoryIdentity({ ...remote, originUrl: remotes.get("origin"), rootPath: cacheKey }) | ||
| 161 | : null; | ||
| 141 | }); | 162 | }); |
| 142 | 163 | ||
| 143 | export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( | 164 | export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( |
docs/internals/remote.md+4| ... | @@ -12,6 +12,10 @@ An environment keeps its ID across server restarts and endpoint changes. Saved | ... | @@ -12,6 +12,10 @@ An environment keeps its ID across server restarts and endpoint changes. Saved |
| 12 | connections are local to a client profile; the server's identity and state are | 12 | connections are local to a client profile; the server's identity and state are |
| 13 | not. A repository identity can correlate clones across environments, but never | 13 | not. A repository identity can correlate clones across environments, but never |
| 14 | routes work between them. A project and its threads belong to one environment. | 14 | routes work between them. A project and its threads belong to one environment. |
| 15 | The canonical key follows the `upstream` remote when one exists, so pull request | ||
| 16 | features target the repository a fork tracks. A fork also reports its own | ||
| 17 | `origin`, and clients group and label by that, so a fork never collapses into a | ||
| 18 | checkout of its upstream. | ||
| 15 | 19 | ||
| 16 | [Environment ID initialization](../../apps/server/src/environment/ServerEnvironment.ts) | 20 | [Environment ID initialization](../../apps/server/src/environment/ServerEnvironment.ts) |
| 17 | must publish a complete ID atomically. Repair of an empty ID file retains a | 21 | must publish a complete ID atomically. Repair of an empty ID file retains a |
packages/client-runtime/src/state/projectGrouping.test.ts+39| ... | @@ -158,6 +158,45 @@ describe("buildProjectGroups", () => { | ... | @@ -158,6 +158,45 @@ describe("buildProjectGroups", () => { |
| 158 | ); | 158 | ); |
| 159 | }); | 159 | }); |
| 160 | 160 | ||
| 161 | it("keeps a fork apart from its upstream checkout and labels it by its own remote", () => { | ||
| 162 | const fork = makeProject("fork", "/work/t3code-fork", { | ||
| 163 | repositoryIdentity: { | ||
| 164 | ...repositoryIdentity, | ||
| 165 | origin: { | ||
| 166 | canonicalKey: "github.com/julius/t3code-fork", | ||
| 167 | displayName: "julius/t3code-fork", | ||
| 168 | }, | ||
| 169 | }, | ||
| 170 | }); | ||
| 171 | const forkWorktree = makeProject("fork-2", "/work/t3code-fork-2", { | ||
| 172 | repositoryIdentity: fork.repositoryIdentity, | ||
| 173 | }); | ||
| 174 | const projects = [makeProject("t3code", "/work/t3code"), fork, forkWorktree]; | ||
| 175 | |||
| 176 | const groups = buildProjectGroups({ projects, settings: settings("repository") }); | ||
| 177 | expect(groups.map((group) => group.key)).toEqual([ | ||
| 178 | "github.com/t3tools/t3code", | ||
| 179 | "github.com/julius/t3code-fork", | ||
| 180 | ]); | ||
| 181 | expect(groups[1]?.members.map((member) => member.project.id)).toEqual(["fork", "fork-2"]); | ||
| 182 | expect(groups[1]?.label).toBe("julius/t3code-fork"); | ||
| 183 | }); | ||
| 184 | |||
| 185 | it("labels a fork by its canonical key when its origin has no display name", () => { | ||
| 186 | const identity = { | ||
| 187 | ...repositoryIdentity, | ||
| 188 | origin: { canonicalKey: "internal-host" }, | ||
| 189 | }; | ||
| 190 | const projects = [ | ||
| 191 | makeProject("fork", "/work/fork", { repositoryIdentity: identity }), | ||
| 192 | makeProject("fork-2", "/work/fork-2", { repositoryIdentity: identity }), | ||
| 193 | ]; | ||
| 194 | |||
| 195 | const groups = buildProjectGroups({ projects, settings: settings("repository") }); | ||
| 196 | expect(groups).toHaveLength(1); | ||
| 197 | expect(groups[0]?.label).toBe("internal-host"); | ||
| 198 | }); | ||
| 199 | |||
| 161 | it("keeps physical clones in separate groups when requested", () => { | 200 | it("keeps physical clones in separate groups when requested", () => { |
| 162 | const projects = [ | 201 | const projects = [ |
| 163 | makeProject("t3code", "/work/t3code"), | 202 | makeProject("t3code", "/work/t3code"), |
packages/client-runtime/src/state/projectGrouping.ts+12-6| ... | @@ -1,8 +1,10 @@ | ... | @@ -1,8 +1,10 @@ |
| 1 | import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts"; | 1 | import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts"; |
| 2 | import type { | 2 | import { |
| 3 | EnvironmentId, | 3 | repositoryGroupingDisplayNameOf, |
| 4 | ScopedProjectRef, | 4 | repositoryGroupingKeyOf, |
| 5 | SidebarProjectGroupingMode, | 5 | type EnvironmentId, |
| 6 | type ScopedProjectRef, | ||
| 7 | type SidebarProjectGroupingMode, | ||
| 6 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 7 | import type { ClientSettings } from "@t3tools/contracts/settings"; | 9 | import type { ClientSettings } from "@t3tools/contracts/settings"; |
| 8 | 10 | ||
| ... | @@ -100,7 +102,9 @@ function deriveRepositoryScopedKey( | ... | @@ -100,7 +102,9 @@ function deriveRepositoryScopedKey( |
| 100 | project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">, | 102 | project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">, |
| 101 | groupingMode: SidebarProjectGroupingMode, | 103 | groupingMode: SidebarProjectGroupingMode, |
| 102 | ): string | null { | 104 | ): string | null { |
| 103 | const canonicalKey = project.repositoryIdentity?.canonicalKey; | 105 | const canonicalKey = project.repositoryIdentity |
| 106 | ? repositoryGroupingKeyOf(project.repositoryIdentity) | ||
| 107 | : null; | ||
| 104 | if (!canonicalKey) { | 108 | if (!canonicalKey) { |
| 105 | return null; | 109 | return null; |
| 106 | } | 110 | } |
| ... | @@ -158,7 +162,9 @@ export function deriveProjectGroupLabel(input: { | ... | @@ -158,7 +162,9 @@ export function deriveProjectGroupLabel(input: { |
| 158 | }): string { | 162 | }): string { |
| 159 | const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title)); | 163 | const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title)); |
| 160 | const sharedDisplayNames = uniqueNonEmptyValues( | 164 | const sharedDisplayNames = uniqueNonEmptyValues( |
| 161 | input.members.map((member) => member.repositoryIdentity?.displayName), | 165 | input.members.map((member) => |
| 166 | member.repositoryIdentity ? repositoryGroupingDisplayNameOf(member.repositoryIdentity) : null, | ||
| 167 | ), | ||
| 162 | ); | 168 | ); |
| 163 | const sharedRepositoryNames = uniqueNonEmptyValues( | 169 | const sharedRepositoryNames = uniqueNonEmptyValues( |
| 164 | input.members.map((member) => member.repositoryIdentity?.name), | 170 | input.members.map((member) => member.repositoryIdentity?.name), |
packages/contracts/src/environment.ts+24| ... | @@ -229,6 +229,17 @@ export const RepositoryIdentityLocator = Schema.Struct({ | ... | @@ -229,6 +229,17 @@ export const RepositoryIdentityLocator = Schema.Struct({ |
| 229 | }); | 229 | }); |
| 230 | export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; | 230 | export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; |
| 231 | 231 | ||
| 232 | /** | ||
| 233 | * The checkout's own remote when it names a different repository than the canonical one, such as | ||
| 234 | * a fork that tracks its upstream. Clients group and label by it so a fork stays distinct from the | ||
| 235 | * repository it forked, while pull request features keep the canonical identity. | ||
| 236 | */ | ||
| 237 | export const RepositoryOrigin = Schema.Struct({ | ||
| 238 | canonicalKey: TrimmedNonEmptyString, | ||
| 239 | displayName: Schema.optionalKey(TrimmedNonEmptyString), | ||
| 240 | }); | ||
| 241 | export type RepositoryOrigin = typeof RepositoryOrigin.Type; | ||
| 242 | |||
| 232 | export const RepositoryIdentity = Schema.Struct({ | 243 | export const RepositoryIdentity = Schema.Struct({ |
| 233 | canonicalKey: TrimmedNonEmptyString, | 244 | canonicalKey: TrimmedNonEmptyString, |
| 234 | locator: RepositoryIdentityLocator, | 245 | locator: RepositoryIdentityLocator, |
| ... | @@ -239,9 +250,22 @@ export const RepositoryIdentity = Schema.Struct({ | ... | @@ -239,9 +250,22 @@ export const RepositoryIdentity = Schema.Struct({ |
| 239 | provider: Schema.optionalKey(TrimmedNonEmptyString), | 250 | provider: Schema.optionalKey(TrimmedNonEmptyString), |
| 240 | owner: Schema.optionalKey(TrimmedNonEmptyString), | 251 | owner: Schema.optionalKey(TrimmedNonEmptyString), |
| 241 | name: Schema.optionalKey(TrimmedNonEmptyString), | 252 | name: Schema.optionalKey(TrimmedNonEmptyString), |
| 253 | origin: Schema.optionalKey(RepositoryOrigin), | ||
| 242 | }); | 254 | }); |
| 243 | export type RepositoryIdentity = typeof RepositoryIdentity.Type; | 255 | export type RepositoryIdentity = typeof RepositoryIdentity.Type; |
| 244 | 256 | ||
| 257 | /** Key clients group checkouts by: a fork's own remote, otherwise the canonical repository. */ | ||
| 258 | export function repositoryGroupingKeyOf(identity: RepositoryIdentity): string { | ||
| 259 | return identity.origin?.canonicalKey ?? identity.canonicalKey; | ||
| 260 | } | ||
| 261 | |||
| 262 | /** Label clients show for a checkout's repository, matching `repositoryGroupingKeyOf`. */ | ||
| 263 | export function repositoryGroupingDisplayNameOf(identity: RepositoryIdentity): string | undefined { | ||
| 264 | return identity.origin | ||
| 265 | ? (identity.origin.displayName ?? identity.origin.canonicalKey) | ||
| 266 | : identity.displayName; | ||
| 267 | } | ||
| 268 | |||
| 245 | export const ScopedProjectRef = Schema.Struct({ | 269 | export const ScopedProjectRef = Schema.Struct({ |
| 246 | environmentId: EnvironmentId, | 270 | environmentId: EnvironmentId, |
| 247 | projectId: ProjectId, | 271 | projectId: ProjectId, |