| 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 | 16 | DEFAULT_RUNTIME_MODE, |
| 17 | 17 | DEFAULT_SERVER_SETTINGS, |
| 18 | 18 | MessageId, |
| 19 | repositoryGroupingKeyOf, | |
| 19 | 20 | T3_PROJECT_FILE_NAME, |
| 20 | 21 | ThreadId, |
| 21 | 22 | } from "@t3tools/contracts"; |
| ... | ... | @@ -393,7 +394,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 393 | 394 | // whatever unrelated project happens to be first on the other machine. Repository |
| 394 | 395 | // identity is the primary signal; projects that haven't reported one yet (still |
| 395 | 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 | 400 | // `|| null` (not `??`): a pending-task placeholder project can have an empty |
| 398 | 401 | // workspaceRoot, and an "" basename would reject every real host below. |
| 399 | 402 | const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; |
| ... | ... | @@ -414,7 +417,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 414 | 417 | if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { |
| 415 | 418 | return true; |
| 416 | 419 | } |
| 417 | const projectKey = project.repositoryIdentity?.canonicalKey ?? null; | |
| 420 | const projectKey = project.repositoryIdentity | |
| 421 | ? repositoryGroupingKeyOf(project.repositoryIdentity) | |
| 422 | : null; | |
| 418 | 423 | if (selectedRepositoryKey !== null && projectKey !== null) { |
| 419 | 424 | return projectKey === selectedRepositoryKey; |
| 420 | 425 | } |
apps/mobile/src/features/threads/new-task-project-selection.ts+11-4| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 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 | 4 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 5 | 5 | import type { HomeProjectScope } from "../home/homeThreadList"; |
| ... | ... | @@ -56,7 +56,9 @@ export function resolveEnvironmentProjectMatch( |
| 56 | 56 | projectsOnTarget: ReadonlyArray<EnvironmentProject>, |
| 57 | 57 | selectedProject: EnvironmentProject | null, |
| 58 | 58 | ): EnvironmentProject | null { |
| 59 | const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; | |
| 59 | const repositoryKey = selectedProject?.repositoryIdentity | |
| 60 | ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) | |
| 61 | : null; | |
| 60 | 62 | // `|| null` (not `??`): a pending-task placeholder project can have an empty |
| 61 | 63 | // workspaceRoot, and an "" basename would match nothing meaningful. |
| 62 | 64 | const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; |
| ... | ... | @@ -64,13 +66,18 @@ export function resolveEnvironmentProjectMatch( |
| 64 | 66 | // side; two known, different repositories never match on a shared basename |
| 65 | 67 | // or title (mirrors the environment list filter in the new-task flow). |
| 66 | 68 | const isKnownMismatch = (project: EnvironmentProject) => { |
| 67 | const projectKey = project.repositoryIdentity?.canonicalKey ?? null; | |
| 69 | const projectKey = project.repositoryIdentity | |
| 70 | ? repositoryGroupingKeyOf(project.repositoryIdentity) | |
| 71 | : null; | |
| 68 | 72 | return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey; |
| 69 | 73 | }; |
| 70 | 74 | return ( |
| 71 | 75 | (repositoryKey !== null |
| 72 | 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 | 82 | : undefined) ?? |
| 76 | 83 | (workspaceBasename !== null |
apps/server/src/project/RepositoryIdentityResolver.test.ts+23| ... | ... | @@ -288,6 +288,29 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { |
| 288 | 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 | 314 | it.effect("uses the last remote path segment as the repository name for nested groups", () => |
| 292 | 315 | Effect.gen(function* () { |
| 293 | 316 | const fileSystem = yield* FileSystem.FileSystem; |
apps/server/src/project/RepositoryIdentityResolver.ts+24-3| ... | ... | @@ -70,17 +70,34 @@ function pickPrimaryRemote( |
| 70 | 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 | 88 | function buildRepositoryIdentity(input: { |
| 74 | 89 | readonly remoteName: string; |
| 75 | 90 | readonly remoteUrl: string; |
| 91 | readonly originUrl: string | undefined; | |
| 76 | 92 | readonly rootPath: string; |
| 77 | 93 | }): RepositoryIdentity { |
| 78 | 94 | const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); |
| 79 | 95 | const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); |
| 80 | const repositoryPath = canonicalKey.split("/").slice(1).join("/"); | |
| 96 | const repositoryPath = repositoryPathOf(canonicalKey); | |
| 81 | 97 | const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); |
| 82 | 98 | const [owner] = repositoryPathSegments; |
| 83 | 99 | const repositoryName = repositoryPathSegments.at(-1); |
| 100 | const origin = buildRepositoryOrigin(input.originUrl, canonicalKey); | |
| 84 | 101 | |
| 85 | 102 | return { |
| 86 | 103 | canonicalKey, |
| ... | ... | @@ -94,6 +111,7 @@ function buildRepositoryIdentity(input: { |
| 94 | 111 | ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}), |
| 95 | 112 | ...(owner ? { owner } : {}), |
| 96 | 113 | ...(repositoryName ? { name: repositoryName } : {}), |
| 114 | ...(origin ? { origin } : {}), | |
| 97 | 115 | }; |
| 98 | 116 | } |
| 99 | 117 | |
| ... | ... | @@ -136,8 +154,11 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( |
| 136 | 154 | return null; |
| 137 | 155 | } |
| 138 | 156 | |
| 139 | const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); | |
| 140 | return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; | |
| 157 | const remotes = parseRemoteFetchUrls(remoteResult.value.stdout); | |
| 158 | const remote = pickPrimaryRemote(remotes); | |
| 159 | return remote | |
| 160 | ? buildRepositoryIdentity({ ...remote, originUrl: remotes.get("origin"), rootPath: cacheKey }) | |
| 161 | : null; | |
| 141 | 162 | }); |
| 142 | 163 | |
| 143 | 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 | 12 | connections are local to a client profile; the server's identity and state are |
| 13 | 13 | not. A repository identity can correlate clones across environments, but never |
| 14 | 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 | 20 | [Environment ID initialization](../../apps/server/src/environment/ServerEnvironment.ts) |
| 17 | 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 | 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 | 200 | it("keeps physical clones in separate groups when requested", () => { |
| 162 | 201 | const projects = [ |
| 163 | 202 | makeProject("t3code", "/work/t3code"), |
packages/client-runtime/src/state/projectGrouping.ts+12-6| ... | ... | @@ -1,8 +1,10 @@ |
| 1 | 1 | import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts"; |
| 2 | import type { | |
| 3 | EnvironmentId, | |
| 4 | ScopedProjectRef, | |
| 5 | SidebarProjectGroupingMode, | |
| 2 | import { | |
| 3 | repositoryGroupingDisplayNameOf, | |
| 4 | repositoryGroupingKeyOf, | |
| 5 | type EnvironmentId, | |
| 6 | type ScopedProjectRef, | |
| 7 | type SidebarProjectGroupingMode, | |
| 6 | 8 | } from "@t3tools/contracts"; |
| 7 | 9 | import type { ClientSettings } from "@t3tools/contracts/settings"; |
| 8 | 10 | |
| ... | ... | @@ -100,7 +102,9 @@ function deriveRepositoryScopedKey( |
| 100 | 102 | project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">, |
| 101 | 103 | groupingMode: SidebarProjectGroupingMode, |
| 102 | 104 | ): string | null { |
| 103 | const canonicalKey = project.repositoryIdentity?.canonicalKey; | |
| 105 | const canonicalKey = project.repositoryIdentity | |
| 106 | ? repositoryGroupingKeyOf(project.repositoryIdentity) | |
| 107 | : null; | |
| 104 | 108 | if (!canonicalKey) { |
| 105 | 109 | return null; |
| 106 | 110 | } |
| ... | ... | @@ -158,7 +162,9 @@ export function deriveProjectGroupLabel(input: { |
| 158 | 162 | }): string { |
| 159 | 163 | const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title)); |
| 160 | 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 | 169 | const sharedRepositoryNames = uniqueNonEmptyValues( |
| 164 | 170 | input.members.map((member) => member.repositoryIdentity?.name), |
packages/contracts/src/environment.ts+24| ... | ... | @@ -229,6 +229,17 @@ export const RepositoryIdentityLocator = Schema.Struct({ |
| 229 | 229 | }); |
| 230 | 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 | 243 | export const RepositoryIdentity = Schema.Struct({ |
| 233 | 244 | canonicalKey: TrimmedNonEmptyString, |
| 234 | 245 | locator: RepositoryIdentityLocator, |
| ... | ... | @@ -239,9 +250,22 @@ export const RepositoryIdentity = Schema.Struct({ |
| 239 | 250 | provider: Schema.optionalKey(TrimmedNonEmptyString), |
| 240 | 251 | owner: Schema.optionalKey(TrimmedNonEmptyString), |
| 241 | 252 | name: Schema.optionalKey(TrimmedNonEmptyString), |
| 253 | origin: Schema.optionalKey(RepositoryOrigin), | |
| 242 | 254 | }); |
| 243 | 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 | 269 | export const ScopedProjectRef = Schema.Struct({ |
| 246 | 270 | environmentId: EnvironmentId, |
| 247 | 271 | projectId: ProjectId, |