From 2706a42be264622bb603d83ded448009056b9a51 Mon Sep 17 00:00:00 2001 From: Project516 <138796702+Project516@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:11:14 -0500 Subject: [PATCH] fix(server): keep forks separate from their upstream repository in project grouping --- .../threads/new-task-flow-provider.tsx | 9 ++++- .../threads/new-task-project-selection.ts | 15 +++++-- .../RepositoryIdentityResolver.test.ts | 23 +++++++++++ .../src/project/RepositoryIdentityResolver.ts | 27 +++++++++++-- docs/internals/remote.md | 4 ++ .../src/state/projectGrouping.test.ts | 39 +++++++++++++++++++ .../src/state/projectGrouping.ts | 18 ++++++--- packages/contracts/src/environment.ts | 24 ++++++++++++ 8 files changed, 144 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 1ffa7696250e..899015c2aae7 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -16,6 +16,7 @@ import { DEFAULT_RUNTIME_MODE, DEFAULT_SERVER_SETTINGS, MessageId, + repositoryGroupingKeyOf, T3_PROJECT_FILE_NAME, ThreadId, } from "@t3tools/contracts"; @@ -393,7 +394,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // whatever unrelated project happens to be first on the other machine. Repository // identity is the primary signal; projects that haven't reported one yet (still // indexing) fall back to workspace basename / title so a valid host isn't hidden. - const selectedRepositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; + const selectedRepositoryKey = selectedProject?.repositoryIdentity + ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) + : null; // `|| null` (not `??`): a pending-task placeholder project can have an empty // workspaceRoot, and an "" basename would reject every real host below. const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; @@ -414,7 +417,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { return true; } - const projectKey = project.repositoryIdentity?.canonicalKey ?? null; + const projectKey = project.repositoryIdentity + ? repositoryGroupingKeyOf(project.repositoryIdentity) + : null; if (selectedRepositoryKey !== null && projectKey !== null) { return projectKey === selectedRepositoryKey; } diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 27d1303c3a45..9270d1737ba7 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -1,5 +1,5 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { repositoryGroupingKeyOf, type EnvironmentId } from "@t3tools/contracts"; import { scopedProjectKey } from "../../lib/scopedEntities"; import type { HomeProjectScope } from "../home/homeThreadList"; @@ -56,7 +56,9 @@ export function resolveEnvironmentProjectMatch( projectsOnTarget: ReadonlyArray, selectedProject: EnvironmentProject | null, ): EnvironmentProject | null { - const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; + const repositoryKey = selectedProject?.repositoryIdentity + ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) + : null; // `|| null` (not `??`): a pending-task placeholder project can have an empty // workspaceRoot, and an "" basename would match nothing meaningful. const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; @@ -64,13 +66,18 @@ export function resolveEnvironmentProjectMatch( // side; two known, different repositories never match on a shared basename // or title (mirrors the environment list filter in the new-task flow). const isKnownMismatch = (project: EnvironmentProject) => { - const projectKey = project.repositoryIdentity?.canonicalKey ?? null; + const projectKey = project.repositoryIdentity + ? repositoryGroupingKeyOf(project.repositoryIdentity) + : null; return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey; }; return ( (repositoryKey !== null ? projectsOnTarget.find( - (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, + (project) => + (project.repositoryIdentity + ? repositoryGroupingKeyOf(project.repositoryIdentity) + : null) === repositoryKey, ) : undefined) ?? (workspaceBasename !== null diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 4996e4023507..97ddf0ca1e8a 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -288,6 +288,29 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); + it.effect("reports a fork's own remote as origin next to the upstream identity", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-fork-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code-fork.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?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(identity?.displayName).toBe("t3tools/t3code"); + expect(identity?.origin).toEqual({ + canonicalKey: "github.com/julius/t3code-fork", + displayName: "julius/t3code-fork", + }); + }).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; diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 5acafa47e2e2..582f452bf12a 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -70,17 +70,34 @@ function pickPrimaryRemote( return remoteName && remoteUrl ? { remoteName, remoteUrl } : null; } +function repositoryPathOf(canonicalKey: string): string { + return canonicalKey.split("/").slice(1).join("/"); +} + +function buildRepositoryOrigin( + originUrl: string | undefined, + canonicalKey: string, +): RepositoryIdentity["origin"] { + if (!originUrl) return undefined; + const originKey = normalizeGitRemoteUrl(originUrl); + if (originKey === canonicalKey) return undefined; + const displayName = repositoryPathOf(originKey); + return { canonicalKey: originKey, ...(displayName ? { displayName } : {}) }; +} + function buildRepositoryIdentity(input: { readonly remoteName: string; readonly remoteUrl: string; + readonly originUrl: string | undefined; readonly rootPath: string; }): RepositoryIdentity { const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); - const repositoryPath = canonicalKey.split("/").slice(1).join("/"); + const repositoryPath = repositoryPathOf(canonicalKey); const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); const [owner] = repositoryPathSegments; const repositoryName = repositoryPathSegments.at(-1); + const origin = buildRepositoryOrigin(input.originUrl, canonicalKey); return { canonicalKey, @@ -94,6 +111,7 @@ function buildRepositoryIdentity(input: { ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}), ...(owner ? { owner } : {}), ...(repositoryName ? { name: repositoryName } : {}), + ...(origin ? { origin } : {}), }; } @@ -136,8 +154,11 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn( return null; } - const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); - return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; + const remotes = parseRemoteFetchUrls(remoteResult.value.stdout); + const remote = pickPrimaryRemote(remotes); + return remote + ? buildRepositoryIdentity({ ...remote, originUrl: remotes.get("origin"), rootPath: cacheKey }) + : null; }); export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 04d5a8f9dd88..3f9e64b03cf1 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -12,6 +12,10 @@ An environment keeps its ID across server restarts and endpoint changes. Saved connections are local to a client profile; the server's identity and state are not. A repository identity can correlate clones across environments, but never routes work between them. A project and its threads belong to one environment. +The canonical key follows the `upstream` remote when one exists, so pull request +features target the repository a fork tracks. A fork also reports its own +`origin`, and clients group and label by that, so a fork never collapses into a +checkout of its upstream. [Environment ID initialization](../../apps/server/src/environment/ServerEnvironment.ts) must publish a complete ID atomically. Repair of an empty ID file retains a diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 4884c3b99bbc..958b04073de9 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -158,6 +158,45 @@ describe("buildProjectGroups", () => { ); }); + it("keeps a fork apart from its upstream checkout and labels it by its own remote", () => { + const fork = makeProject("fork", "/work/t3code-fork", { + repositoryIdentity: { + ...repositoryIdentity, + origin: { + canonicalKey: "github.com/julius/t3code-fork", + displayName: "julius/t3code-fork", + }, + }, + }); + const forkWorktree = makeProject("fork-2", "/work/t3code-fork-2", { + repositoryIdentity: fork.repositoryIdentity, + }); + const projects = [makeProject("t3code", "/work/t3code"), fork, forkWorktree]; + + const groups = buildProjectGroups({ projects, settings: settings("repository") }); + expect(groups.map((group) => group.key)).toEqual([ + "github.com/t3tools/t3code", + "github.com/julius/t3code-fork", + ]); + expect(groups[1]?.members.map((member) => member.project.id)).toEqual(["fork", "fork-2"]); + expect(groups[1]?.label).toBe("julius/t3code-fork"); + }); + + it("labels a fork by its canonical key when its origin has no display name", () => { + const identity = { + ...repositoryIdentity, + origin: { canonicalKey: "internal-host" }, + }; + const projects = [ + makeProject("fork", "/work/fork", { repositoryIdentity: identity }), + makeProject("fork-2", "/work/fork-2", { repositoryIdentity: identity }), + ]; + + const groups = buildProjectGroups({ projects, settings: settings("repository") }); + expect(groups).toHaveLength(1); + expect(groups[0]?.label).toBe("internal-host"); + }); + it("keeps physical clones in separate groups when requested", () => { const projects = [ makeProject("t3code", "/work/t3code"), diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index ce5c984214fd..b1abd6685262 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -1,8 +1,10 @@ import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts"; -import type { - EnvironmentId, - ScopedProjectRef, - SidebarProjectGroupingMode, +import { + repositoryGroupingDisplayNameOf, + repositoryGroupingKeyOf, + type EnvironmentId, + type ScopedProjectRef, + type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import type { ClientSettings } from "@t3tools/contracts/settings"; @@ -100,7 +102,9 @@ function deriveRepositoryScopedKey( project: Pick, groupingMode: SidebarProjectGroupingMode, ): string | null { - const canonicalKey = project.repositoryIdentity?.canonicalKey; + const canonicalKey = project.repositoryIdentity + ? repositoryGroupingKeyOf(project.repositoryIdentity) + : null; if (!canonicalKey) { return null; } @@ -158,7 +162,9 @@ export function deriveProjectGroupLabel(input: { }): string { const sharedTitles = uniqueNonEmptyValues(input.members.map((member) => member.title)); const sharedDisplayNames = uniqueNonEmptyValues( - input.members.map((member) => member.repositoryIdentity?.displayName), + input.members.map((member) => + member.repositoryIdentity ? repositoryGroupingDisplayNameOf(member.repositoryIdentity) : null, + ), ); const sharedRepositoryNames = uniqueNonEmptyValues( input.members.map((member) => member.repositoryIdentity?.name), diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 5444a2645a40..7cd4654ba053 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -229,6 +229,17 @@ export const RepositoryIdentityLocator = Schema.Struct({ }); export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; +/** + * The checkout's own remote when it names a different repository than the canonical one, such as + * a fork that tracks its upstream. Clients group and label by it so a fork stays distinct from the + * repository it forked, while pull request features keep the canonical identity. + */ +export const RepositoryOrigin = Schema.Struct({ + canonicalKey: TrimmedNonEmptyString, + displayName: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type RepositoryOrigin = typeof RepositoryOrigin.Type; + export const RepositoryIdentity = Schema.Struct({ canonicalKey: TrimmedNonEmptyString, locator: RepositoryIdentityLocator, @@ -239,9 +250,22 @@ export const RepositoryIdentity = Schema.Struct({ provider: Schema.optionalKey(TrimmedNonEmptyString), owner: Schema.optionalKey(TrimmedNonEmptyString), name: Schema.optionalKey(TrimmedNonEmptyString), + origin: Schema.optionalKey(RepositoryOrigin), }); export type RepositoryIdentity = typeof RepositoryIdentity.Type; +/** Key clients group checkouts by: a fork's own remote, otherwise the canonical repository. */ +export function repositoryGroupingKeyOf(identity: RepositoryIdentity): string { + return identity.origin?.canonicalKey ?? identity.canonicalKey; +} + +/** Label clients show for a checkout's repository, matching `repositoryGroupingKeyOf`. */ +export function repositoryGroupingDisplayNameOf(identity: RepositoryIdentity): string | undefined { + return identity.origin + ? (identity.origin.displayName ?? identity.origin.canonicalKey) + : identity.displayName; +} + export const ScopedProjectRef = Schema.Struct({ environmentId: EnvironmentId, projectId: ProjectId,